ff23f5d4bd02d5e6d20ba73230f426167d8c558c
26 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ff23f5d4bd |
Films reach the site the same way stills do
`films.mjs` now writes into the sibling lumbridge-v4 checkout — the MP4s and their posters into `apps/web/public/films/`, and a generated `films.ts` beside the shots manifest, carrying each reel's caption, alt text and running time. Same shape as `shots.mjs`, including the `FilmId` union that turns a page naming a deleted reel into a typecheck failure rather than a dead <video>. `--publish <dir>` puts an existing render in front of the site without re-shooting it. Twenty-two minutes a reel is long enough that the alternative would have been `cp`, and a hand-copied artefact is the thing this pipeline exists to not have. `--frames` refuses to publish at all: a 24-frame stutter is a rough cut, not something to put on a website by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b4eb2fa1a8 |
The engine can be filmed, by stepping its clock rather than recording it
`films.mjs` renders time-lapses of the running dist/: a locked camera watching eighteen hours pass, shadow sweeping the city and the windows coming on as the computed sun goes down. Two ship — the Financial District and the whole board. Real-time capture was never on the table and would have been the wrong idea anyway. SwiftShader draws about three frames a second with no GPU, so a screen recording is a slideshow; and a sunset takes an hour, which is not a length of video anybody watches. So it is rendered offline the way films always have been: set the clock, let the frame settle, expose, advance. The output is smooth 30fps regardless of what the renderer managed while being photographed. Two shims in `filmClock()` get that done without a reload per frame, which matters because a reload is fifteen seconds and would put a 180-frame film at three quarters of an hour. The skew is a live global read on every `Date` call rather than baked in at load; and `setInterval(…, 60_000)` is compressed to 120ms, which is the load-bearing half — `main.ts` recomputes the sun on a once-a-minute wall-clock tick, so a shifted clock otherwise sits unrendered. Only the 60-second interval is touched, by value. Both are capture-harness lies told to the page. The deployed bundle has no idea this file exists. Not wired into the site. This is the capability and two reels to look at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
138a342c17 |
The frame matches the page's light rather than opposing it
Correcting the pairing this script documents: the daylit frame goes on the light page and the night frame on the dark one. A reader in dark mode has asked for a dark room, and a bright noon render is a hole punched in it; the hairline frame around each figure is what keeps a night shot from dissolving into the page, which is the border's job rather than the sun's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d04a75b9bd |
Every shot is taken twice, and the site shows you the other one
Each of the seven cameras now fires at two clocks — one daylight frame, one around sixteen degrees below the horizon — and lumbridgecorp.com loads whichever is the opposite of the reader's theme. A night render on that site's dark page is a dark rectangle on a dark page: the edges dissolve and the picture stops being an object. A daylit one on the white page has the same problem in reverse, and the hazier frames wash out entirely. It is also the cheapest demonstration available of the claim those pages lean on hardest. The sun is computed from a real time rather than themed, and a reader who doubts it can hit the light/dark toggle and watch the same city change hour with nothing else about the geometry moving. Shot ids lose their time of day — `soma-afternoon` is `soma`, `fidi-night` is `fidi`, `peninsula-morning` is `peninsula` — because none of them names one hour any more. The manifest gains a `Frame` type: `at`, `src`, `alt` and a `note` belong to an hour, while `place` and `caption` are true of both, so a caption cannot end up describing light that is not in the frame under it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d71e844703 |
The engine gets photographed, and the camera stops moving while the shutter is open
`shots.mjs` renders the product imagery for lumbridgecorp.com/simulate: seven frames of the running `dist/`, at chapters and times chosen rather than defaulted, written as WebP into the sibling site checkout together with a generated manifest that carries each picture's caption and alt text. The pages there had described a renderer in prose for their whole life without ever showing one, which is a strange way to sell a renderer. The captions live next to the camera poses in this repo, not on the site, so re-aiming a camera cannot leave a caption behind describing the old view. The manifest emits a `ShotId` union, so a page asking for a picture that has been renamed fails the site's typecheck instead of rendering a hole. Along the way: the share cards were never reproducible. `scenekit.ts` eases a chapter change over about two seconds of scene time, `stage.ts` clamps `dt` to 50 ms a frame, and SwiftShader here draws about three frames a second — so the flight takes twenty seconds of wall clock and `capture.mjs` waited four. Every run caught the camera at a different point over the bay, and none of them at the chapter the key press asked for. Both scripts now open the page with reduced motion, which is the app's own answer to "somebody clicked a name in a list": `flyTo` sets the pose outright. `og-tera.png` is regenerated and is Hayes Valley for the first time. The bytes still differ run to run, because aircraft are crossing and cloud shadow is drifting; the framing no longer does. `harness.mjs` is the static server, the SwiftShader flags, the two-hostnames- one-dist trick and the clock shim, extracted because there are two consumers now and two copies would have drifted apart while both claimed to photograph the same app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
902481a03d |
The access test follows the sky out from behind the sign-in
Written against a `Capabilities` that had `liveData` on it, and landing on a
master where it does not:
|
||
|
|
37d5320664 |
Both doors get a share card, and they are not the same card
A link to tera.lumbridgecorp.com or office.lumbridgecorp.com unfurled as a bare
blue URL. No picture, no sentence, and a title — "Lumbridge Simulate — San
Francisco" — that was wrong at one of the two doors and stale at the other.
There were no `og:` or `twitter:` tags in the document at all. For a project
whose entire pitch is that you should look at it, that is the most expensive
missing markup in the repo.
Both cards are screenshots of the running app, not drawings of it, and that is
the load-bearing decision rather than a shortcut. `scripts/brand-assets/` builds
them in two passes: shoot the city and the office out of `dist/`, then render
`og.html` over those shots at exactly 1200x630. The thing is worth looking at,
and a drawing of it goes stale in silence — which is not hypothetical. The card
currently live on lumbridgecorp.com is a viewport screenshot of a marketing page
that has since been rewritten, so that preview advertises a positioning the site
no longer uses, and it has been doing so for a month with nobody noticing. A
card regenerated from `dist/` by one command is a card that can be kept true by
running the command.
The clock is shifted to midday for the capture, because the sun is real —
`observe()` computes it from `new Date()` — and a card regenerated at two in the
morning is an honest photograph of a black rectangle. Shifted rather than
frozen: everything else runs off `requestAnimationFrame`, and a stopped clock
stalls the frame loop the screenshot is waiting on.
Every string on the cards is the project's own. The headlines are what
`README.md` already says each half is; "Clone it and it works" is CONTRACT.md
§0's acceptance test in the words `main.ts` uses for it; the licence in the
corner is the one in the repo root. A share card is the most-read and least
reviewed sentence a project has, which is exactly why it should not be where new
claims get invented.
The harder half was that the two doors are one bundle. The app sorts out which
door it is by reading its own hostname; a crawler cannot, because it reads the
HTML and nothing else — so one `index.html` means both doors unfurl as the same
place, and being a different place is the office door's whole reason to exist. A
second hand-written shell is what 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"), and two 900-line files each carrying the
inline stylesheet would drift on the first CSS change with nothing to notice.
So the build emits both. Everything outside the `ogc:` markers is copied byte for
byte — verified: `office.html` and `index.html` are identical below `</head>` and
point at the same bundle hash — and only the head block differs. A change to the
interface reaches both doors by construction. It runs in `writeBundle` rather
than `transformIndexHtml` because it needs the finished document, after Vite has
rewritten the asset URLs, and it errors rather than no-oping if the markers go
missing: a card that is quietly the wrong one is the failure the plugin exists to
prevent.
`deploy/Caddyfile.snippet` documents the one line that turns it on — the office
door's `try_files` fallback pointing at `/office.html` off the same shared root.
A deployment that skips it is not broken; the office door keeps working and
unfurls with the city's card, which is what it did before.
Not deployed. The live Caddyfile still falls back to /index.html for the office
host, so this needs that one-line change on cloud-2 before office.
lumbridgecorp.com unfurls as the office.
Both shells boot clean in Chrome with zero console errors, 31 client tests and
the no-binary gate still pass — `public/` is exempt from it, which is where the
two PNGs live.
|
||
|
|
aab58a1c24 |
The client gets tests, starting with the two files that most needed them
Nine test files on the server, none on the client, and no test script in the root package at all — so CI's only gate on the half of this project that runs in a stranger's browser was `tsc --noEmit`, which will tell you the types line up and nothing about whether an anonymous visitor is handed the private office. Both files here were built to be tested and never were. `access.ts` is the only module in the bundle whose output is a set of decisions about what a stranger may see, and its own header carries the reason a test is owed: a shipped line read `canEnterOffice = s.authenticated || !s.passwordLogin`, which is true for `auth: none` and dangerously false for `sso` — where `POST /session` answers 404 precisely *because* credentials are issued elsewhere — so on an SSO deployment every anonymous visitor got the private view while the config still said the box was private. There is now a test named after that bug. The assertions are weighted toward the closed direction on purpose: one that a member gets what a member is owed, and half a dozen that nobody gets more than nothing, because showing a member the public office is a bad afternoon and showing a stranger the private one is what the tiers exist to prevent. The 5xx case is in there too — a box mid-restart is `anon`, not `member` — and so is the `javascript:` entry URL, which a CSP of `script-src 'self' 'unsafe-inline'` does not stop from navigating. `plan.ts` says in its own header that it imports no three.js "so the splitting pass is testable without a WebGL context", and that it "drops rather than throws … every one of those is reported through `problems`" — an array whose whole purpose is to be asserted on, which nothing asserted on. So: a pack wrong in five ways still builds and files five reports; a zero-length wall does not put a NaN in the bounds; an office with no levels stays finite so nothing downstream divides by it; a pack with the required arrays missing is taken, because HTTP will send one. And the wall pass gets the check CONTRACT.md §2's argument deserves — one decomposition, two products — by walking a walker through the door and into the wall beside it, and through a window and being stopped. Also the yaw convention, which nothing stated and `officeMinimap.ts` draws straight from: get the sign wrong and every wall mirrors about its own centre, invisible on a square and obvious on anything else. No new dependency. The server already runs `node --test` over `.ts` on native type stripping, so the client does the same — which matters here, because this repo's "no surprise dependencies" check is an allowlist naming why each one is permitted, and a test runner would have needed an entry and an argument. One source change was needed to make any of it possible. `session.ts` read `import.meta.env.VITE_IDENTITY_URL` at module scope, and `access.ts` imports `authFetch` from it — so one property access made the file that decides what an anonymous visitor sees unreachable from a plain test runner, which is most of why it had no tests. It now reads the way `plan.ts` already reads `DEV`, by the idiom that file documents as being there "so this module stays importable from a plain test runner". 31 tests, 9 suites, all passing, wired into `npm test` and into the CI job beside the server's. `vite build` is unchanged and no test code reaches the bundle. |
||
|
|
270cddda31 |
The phone can reach the plan, read the disclosure, and see the room
Three things, all on the door people actually open. office.lumbridgecorp.com is a link you send somebody, and a link somebody is sent is opened on a phone. **The plan had no way in.** The stylesheet builds it a whole bottom-sheet layout below 600px — full width above the rail, 38dvh tall, hover-only text dropped — and the only thing that could open it was the `M` key, which on a phone is not a control that is hard to find but a control that does not exist. The comment above that layout says "it is still behind `M`, so it costs nothing until it is asked for", and on a phone it could not be asked for. So there is a button, in the one cluster a thumb reaches, at every width rather than only the narrow ones — a control only a keyboard can reach is a control some people do not have, and that is as true at 1920 as at 390. It is glyph-only, because the `M` hint beside it already carries the word and two controls labelled "plan" in one bar is one too many, and it carries `aria-pressed` for the same reason the chapter buttons do: a toggle that does not look like its state is a button that appears to do nothing on every second press. **The disclosure was behind a hamburger.** "Sample occupancy — these people are invented" lived on `#office-badge` inside `#panel`, and on a phone `#panel` is a sheet that starts closed. The one sentence standing between twenty-five invented people at real desks and a screenshot presented as a staff list was hidden, on the device most likely to take the screenshot. It moves to `#source`, which is where this page already says what on screen is and is not real, is fixed and always drawn, and which the phone stylesheet itself calls "the one caption that is never allowed to be dropped for space". It replaces the city's liveness line while you are inside rather than joining it, because the markers, the sky and the traffic are facts about a board behind you — and "live data" printed over invented colleagues is the exact species of lie that wording was rewritten to stop telling. It also needed a rule of its own: `.source` on a phone is one nowrap ellipsised line, which rendered this as "sample occupancy · these people …", a truncation that removes the only word that mattered. A disclosure cut off mid-clause is worse than none, because it looks like a caption somebody bothered to write and nobody goes looking for the rest. **The room did not fit.** `camera.fov` is vertical, so visible width is `distance * tan(fov/2) * aspect`, and a phone held upright has an aspect near 0.5 against the 16:9 those viewpoints were framed by eye against. At the same distance that is a third of the width: the arrival shot put a thirty-four-metre floor plate off the corner of the screen under a frame full of empty sky. The pack was not wrong and the renderer was not wrong — the number meant something else on that screen. `poseFor` now preserves the width the author framed, which is the thing they were choosing; "everything in this building is somewhere in this frame" is a statement about width, and the extra height a tall screen throws in costs nothing. It only ever pushes back, never pulls in, so a window wider than 16:9 is untouched. The correction scales the whole offset from the target and not the ground distance alone, and that is the entire fix rather than a refinement. Multiplying only the distance does not step back from a shot, it flattens it: 32 m out and 14 m up became ninety-odd metres out and still fourteen up, a near-horizontal squint at the edge of a floor plate stranded near the horizon. I built that first and it was worse than the bug. Checked on an iPhone 13 viewport and at 1920x1080 and 1440x900. Phone: the whole floor centred at the authored angle with people at the benches, the disclosure on two lines and complete, the button tapped open and the plan sheet up with eight rooms named. Desktop 16:9 is pixel-identical — the correction is exactly 1 there — and the console is clean on all three. |
||
|
|
dac12cecec |
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. |
||
|
|
d188db9299 |
The office plan takes the phone's pixel ceiling too
`minimap.ts` is handed `deviceProfile().maxPixelRatio` and `officeMinimap.ts` was not, so it defaulted to 2 and rendered a phone's floor plan at twice the fill rate the city plan had already decided that handset could afford. Two 2D canvases in one corner of one page disagreeing about what a phone is: exactly the drift `deviceProfile` lives in a single file to prevent, and stage.ts says so at the top. Mine to fix — it arrived with the plan two commits ago. |
||
|
|
3e9b97ed8b |
Signing in puts people in the building
`member` and `anon` were told apart inside the office by the client and by nothing else. `access.ts` picked an `officeDepth`, `createOfficeScene` built a presence layer at full depth — and then nobody ever called `setPresence`, so both tiers rendered the identical empty room. A tier that changes nothing you can see is not a tier, and `routes/markers.ts` had already written down why one drawn only in the browser is worse than none: it is a UI hiding a control over a body the API hands to whoever asks. So the refusal happens on the server now. `GET /api/v1/offices/:id/presence` is the one route that always takes a session, whatever else the deployment is configured for. `markers.ts` serves its feed to anonymous callers when no feed is configured, on the grounds that there is nothing there to protect; that reasoning does not transfer, and the difference is the whole point — a marker is a company at an address and a presence is a person at a desk. The ordering inside the handler is the security property, not a detail. It resolves the viewer *before* it looks at the id, so an anonymous caller gets an identical 401 for a real office, a private one and one that was never created. Check the office first and 404-for-unknown against 401-for-known tells them apart perfectly, which is the enumeration oracle CONTRACT.md §6 forbids, wearing a different status code. Three requests and one `deepEqual` hold that down. `TERA_PRESENCE_DIR` is a second directory rather than a `people` field on the pack, and that is the design. `types.ts` says a `Presence` binds to a `seatId` and never to a coordinate so the geometry can be published while the people cannot — which buys nothing if both live in one file, because an operator who wants a public floorplan then has to strip the roster out by hand, and the first time they forget the leak is permanent. Two directories makes the safe thing the default thing. An office with no roster is 200 and empty, never 404: "no such office" and "nobody has told me who is in this one" are different problems with different fixes, and one 404 sends an operator after the wrong one. On the client, occupancy arrives after the room is on screen rather than before — the building is worth looking at while a second request is in flight. An API that answers is believed, including when it answers with nobody; an office where everyone has gone home is a real fact and overwriting it with invented people to liven up the demo is the one thing this must never do. An API that does not answer falls back to a fabricated roster, exactly as the markers do, because a clone with no server is the flagship case and a member shown the same empty room as a stranger has been told the tier means something when it does not. Those twenty-five people are invented and the page says so. `sample.ts` says it to a reader of the source; `#office-badge` now says "Sample occupancy — these people are invented" to the person looking at the room, and it is not suppressed when a real deployment's API merely happened to be down — that is exactly the case where a member would otherwise read invented names as their colleagues. Fabricated names at real desks look like a staff list, and a screenshot of one must not be possible to take without the caption. The floor plan marks the occupied desks, one colour for everybody where the scene has four: at three device pixels a hue is a guess. The plan answers "is anyone there" and the room answers "who, and what are they doing". Hovering a desk names them, and the readout reads as an address getting more specific — metres, then room, then person. server: 127 tests pass, 11 of them new. Client typechecks and builds; the office chunk absorbed the plan renderer and the entry chunk moved 2.3 kB for the sample roster. Checked in the browser at office.lumbridgecorp.com: FULL VIEW, the badge, figures at the benches, dots on the plan, and "3.7, 16.7 m · Alcatraz · Clementine Roux" under the pointer. |
||
|
|
df534c3530 |
On the office door, the map is of the office
office.lumbridgecorp.com and tera.lumbridgecorp.com are one bundle behind two
names, and the office door had two ways of forgetting which one you came in
through.
The plan panel was the loud one. `createMinimap` is built per city, from
`city.world`, and nothing swapped it when you stepped inside — so the one piece
of chrome whose entire job is to answer "where am I" went on drawing the Bay
Area while the scene in front of it was a thirty-four-metre floor plate. Not
merely unhelpful: pointing at another county.
So there is a second widget for the other place. `officeMinimap.ts` reads the
`Plan` the scene was already built from — no second resolution pass, no geometry
re-derived — and draws it: room slabs, the walls as their `solid` runs only,
which leaves a gap at every door and arch because `Plan` had already split them
for the collider; a glazing hairline across each window, without which the
reference office's north edge simply is not there; furniture at its real
footprint from the asset registry, turned by its own yaw, so four benches of
twelve read as four benches of twelve; room names over a halo, because a room's
centroid is usually the middle of its own desks. Same corner, same `M`, same
camera footprint, same click-to-seek, same keyboard aim. The footprint is cut
against `level.floorY` rather than y=0, which is the same number on a one-storey
pack and a whole storey out on any other.
It is a second module rather than a mode inside the first because the two share
their shape and almost none of their content: one projects lat/lng through a
`World`, rasterises a coastline and a hillshade and follows the sun; this one is
already in metres and lives under a fixed interior rig where the sun does not
reach. One file would have been an `if (city)` at the top of every function.
It arrives in the office chunk, not the entry chunk. It reads prop footprints
off the asset registry, so it is downstream of the furniture catalogue that the
`await import()` in `loadOffice` exists to hold back; a value import in main.ts
would have pulled all of it forward and silently undone the split. So main.ts
takes the constructor as an argument and names the module type-only, and the
build agrees: officeMinimap is its own 10.3 kB chunk and the entry is unmoved.
The quiet one was the URL. Leaving the office swapped the scene and left the
address bar saying `office.` — which is the thing you copy, bookmark and send to
someone, and it took them somewhere other than what you were looking at. From
the `office.` host the exit is now a real navigation to the same hostname with
its first label swapped: the same string comparison that decided this was the
office door decides where the city is, so a deployment serving one name and not
the other gets an honest 404 on a name it chose not to serve rather than a
silent lie. `?city=` carries the metro across so stepping out into SoCal lands
in SoCal, and is read unconditionally because a deep link to a city is a
reasonable thing to want on its own. On `?view=office` there is no other host to
go to, so the scene swap stands and the query is dropped on the way out.
Checked against the production build with both real hostnames resolved at a
local static server: office.lumbridgecorp.com boots into the office with the
floor plan in the panel, the hover readout gives office metres and the room
under the pointer ("9.8, 16.7 m · Bernal"), `O` lands on tera.lumbridgecorp.com
showing the Bay Area, and the console is clean.
|
||
|
|
1db868ad70 |
The sky is not something an account grants you
`liveData` was one flag meaning two unrelated things, and it was set to `tier !== "anon"`. That reasoning does not survive asking what the data actually is. Cloud cover over San Francisco is a reading from a government sensor. The aircraft are broadcasting their positions, unencrypted, to anyone within range who owns a forty-dollar receiver. Neither is withheld from anybody by anybody, so neither is a thing an account can grant access to — and putting them behind a sign-in cost the only moment that makes this project land: real fog rolling off the Pacific onto a city you recognise, at the real time of day, on a first visit. On an SSO-gated deployment it cost that moment for every visitor there currently is. So `liveData` splits. `liveEnvironment` is public and unconditional. `liveMarkers` is asked for by everyone and granted by the server, because the marker set is the one feed here that can carry something private — a company's pipeline, a person's job search — and whether it is public is a property of the deployment, not of a file in this repo. `TERA_MARKERS_ACCESS` is therefore a server switch and its default is `members`, which is the safe answer rather than the common one. The failure mode of getting this wrong is silent: nothing throws, nothing looks broken, the data is simply readable by the internet. An operator who wires real markers up gets the shut door without having chosen it and has to say `public` out loud — and saying it appends a line to `degraded[]`, so `/api/v1/health` reports that this box is publishing its map without anyone having to go and read the env file. Same reasoning as `TERA_ADMIN_SUBJECTS=*`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e41c90fe8d |
Real weather, real aircraft, a heightfield off the main thread, and instruments
Three things that were built and never connected, connected.
**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.
**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.
**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.
**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.
Two blockers the review caught:
- Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
— and ~10.5 shader programs, and deleteTexture had never been called once in
the app's lifetime. The renderer was being built per scene; it belongs to the
canvas, for the life of the page.
- An upstream fetch that threw rather than returning null skipped the cache
stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
upstream request per inbound request, and the caller got a 500.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a6f6a91813 |
A disclosure nobody reads is a watermark
"sample data · fabricated, not real companies" sat in the corner of every load, because no deployment has a markers source wired by default and the sample set is therefore the overwhelmingly common case. A caption that is always on screen stops being read after the first second, which is the opposite of what a disclosure is for. So the corner keeps only the rare half — "live data", which is genuinely worth interrupting someone to say — and the fact that the markers are invented moves to the two places someone actually looks: the boot card everyone passes through, and the `?` card, which is one keypress away from any state the app can be in. It did not go into a README nobody opens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5bc7258753 |
A plan view in the corner, a night you can actually see, and three kinds of visitor
The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.
Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.
Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".
Three things this run found and fixed rather than shipped:
- entryUrl came off the wire and went straight into an href with no scheme
check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
javascript: URL from navigating. One rejection point in access.ts now.
- A 5xx from /health was the same null as "no API at all" and therefore the
opposite conclusion. Eight seconds of tera-api restarting would have told
every anonymous visitor they were a member. A 5xx is an answer; it fails
closed.
- decodeURIComponent in cookieToken was the one path in auth/index.ts that
threw rather than returning ANONYMOUS, so one malformed cookie header from
an unauthenticated caller turned /api/v1/session into a 500.
Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
47faec9f9d |
0.8 was one machine on one run
socal.ts claimed a 0.8 second field build for its two focus regions. Three runs just now give 1.34, 1.33, 1.32 — the published PACKS table on lumbridgecorp.com/simulate/tera already says 1.3, having been measured rather than copied, so the repository was the one out of step. Small, and exactly the drift worth catching: a figure a marketing page cites should not be a number the repository disagrees with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fe43077ff8 |
The same bug the camera had, still live in the default light rig
Three loose ends turned into four, because one of them was not a stale comment. `cityDaylight()` closed its fog at 210/460 — numbers tuned when San Francisco's 230-unit board was the only one. Every caller who supplies an `Atmosphere` overrides them, which is why this survived: the demo does, so nothing looked wrong. A self-hoster who renders a city with no clock and no weather gets the default path, and on the Bay Area's 1003 units that fog closes well inside the city. It now scales to the board exactly as the camera limits do, with the old constants preserved as the default for a caller holding a palette but no world. The three that really were comments: socal.ts justified its `latScale` by citing the 340-unit orbit cap and the 460-unit fog as things the engine insisted on, and the engine stopped insisting when both became board-relative — 285 is now a choice the pack makes because its lot sizes and hill radii were authored against it. CONTRACT.md and ARCHITECTURE.md still measured the retention argument against a 336,864-point San Francisco that has been the whole Bay Area for some time. And server/package.json listed five routes where there are six. None of these changed behaviour except the first. All of them would have gone on quietly disagreeing with the pages that now cite them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
924be42f19 |
Say two, because two is what ships
Trimming Southern California from seven focus regions to two was a measured call — seven came to 0.73M lattice points and a 6.6 second field build against 0.13M and 0.8 — but the comment above the array went on describing seven, with arithmetic for a configuration that no longer existed. That comment is the only place the per-axis limitation of `buildAxis` is written down next to real numbers, so it now records what happened rather than what was planned. Also corrected the coarse cell size (10x, not 6x) and the Bay Area's instance count. The marketing pages cite these figures. A repository comment that disagrees with them is how the two start drifting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
29c93c1dd3 |
An empty live feed is not live data
A server whose marker source is unconfigured answers 200 with an empty array, and the client counted that as success. The deployed map came up with no pins at all, wearing a green "live data" badge — which is the one outcome worse than having no badge, because the badge exists precisely so a fabricated demo cannot be mistaken for real data, and here it was doing the reverse. Empty now falls back to the sample set and keeps saying "sample". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
44c5a79424 |
SoCal, the whole bay, a moon, and gates that actually run
Six agents in parallel, and the two city packs independently reported the same blocker: `focusRegions` and `coarseFactor` existed on the `City` type and nothing implemented them. Uniform lattices would have been 2.9M points for Southern California and 3.7M for the expanded bay. Both packs were unloadable as written. `buildAxis` is the answer, and it is honest about its limits: refinement is per axis, not per rectangle, so a focus region sharpens its whole row *and* its whole column. Two regions at opposite corners refine nearly everything between them. Measured, not guessed — the bay went 0.53M points with one region and 1.64M with three, for detail nobody is looking at from a board this wide. One region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s. Then three things that were only ever right because San Francisco was the only city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a 230-unit board; the bay is 1003 units across and the camera physically could not retreat far enough to frame it. Fog distances were scene units pinned to the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest weather, which over ninety-four kilometres of bay correctly hides three quarters of it — the night view was a black rectangle for a completely reasonable reason. All three now derive from the board. The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a physical ratio of one to four hundred thousand. What is being reproduced is what a moonlit night looks like on a screen in a lit room. The CI gate caught itself, which is the part worth keeping. Port 8431 was already held by a server from an earlier session, so the boot check polled a healthy stranger while the process it started died on EADDRINUSE. It now refuses to run rather than pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8bcb391455 |
Deploy notes for the static map, now that one is actually serving
tera.lumbridgecorp.com is live and the Phaser 2D world at world1 is retired — 301 to Tera, containers stopped rather than removed so the decision is reversible for as long as the images exist. Worth writing down what the deploy actually needs, because it is almost nothing: a file server and a default-src 'self' policy. No API, no keys, no account. The one non-obvious header is img-src needing data: and blob:, and that is a consequence of a licensing choice rather than a technical one — every texture is drawn on a canvas at runtime because a texture that is code has no provenance question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d464459838 |
Spaces: the inside of the world, and a sun that is actually where it should be
Ten agents wrote this in parallel against CONTRACT.md, which exists because the five design agents before them collided on fifteen blocking points — four files specified twice with incompatible contents, three separate backends for one box, and `Environment` exported twice meaning different things. What landed: a Stage owning only the renderer and the loop, with the city and an office as two scenes over it. They cannot share one — San Francisco is ~94 m per scene unit with 3.6x vertical exaggeration and an office is 1 unit = 1 m — and the city is paused rather than disposed on the way in, because rebuilding its 336,864-point heightfield costs about a second on the way back out. Offices are data. `src/offices/lumbridge-hq.ts` is fifteen rooms and seventy-six seats, and it is the file a self-hoster copies. Walls are a segment list with 1-D openings, so doors and windows are holes punched in a wall rather than placed objects, and the pass that splits a wall around its openings hands the walk-mode collider its segments for free. The sun is real. `solar.ts` is a NOAA/Meeus implementation with no imports at all — not even three.js — so time of day keeps working on a laptop in a field. Verified against known values: 75.45 degrees at the June solstice in SF, 28.79 at December, sunset at 03:15Z. The first screenshot after wiring it was a black rectangle, which turned out to be correct: it was midnight in San Francisco. Presence binds to a seat id and never to a coordinate. The pack knows where `eng-04` is; who is sitting in it is private data behind an API. Same shape as the marker rule, one level in. Two corrections to ARCHITECTURE.md are in here. Containment does not discharge ODbL — publishing OSM-derived coordinates is Public Use of a Derivative Database wherever the rows live, so the rule is about the geocoder (US Census, public domain) and not the storage. And a person at a desk is not a Marker; markers are geographic. One contract gap surfaced only in a screenshot: two agents read `height` on a viewpoint differently, so the establishing shot aimed at empty air fourteen metres above the roof. It now means what the same field means for a city. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
36471bbad7 |
Tera, because Simulate was already spoken for
Two names in this repo were quietly colliding with things that already ship. `lumbridge-simulate` is a Rust crate in the private monorepo — the Governor's memory-admission simulator, with a public product page — and in its vocabulary a "Scene" is a set of models, not a three.js scene. Naming a 3D engine the same thing would have put two different meanings on both words at once. Meanwhile world1.lumbridgecorp.com has been serving <title>Tera — Lumbridge</title> since before any of this: the world already had a name. So the map view is Tera, the interiors product is Spaces, an Office is one building's interior and a Space is a room inside it. ARCHITECTURE.md opens with that table now, because the next few thousand lines all depend on it. The Phaser 2D world comes down rather than being migrated. Its tileset is precisely what stopped it being open-sourceable, and there are no real users to strand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
67f8df8d53 |
Lumbridge Simulate Engine — the city, and the licence it can actually ship under
LSE is the third of the three, beside lumbridge-compute and lumbridge-bench: a 3D engine for walkable places. This first commit is the outside of the world — San Francisco — plus the seams the inside will attach to. The engine renders a City and a list of Markers and knows nothing else. It does not know markers are usually companies and it will never learn that "rejected" is red; that mapping lives in an adapter. Which is what lets one renderer serve a private map, a public one, and a self-hoster with no Lumbridge account, none of them a fork of the others. Three things were designed around the licence rather than discovered after it, because each one is a promise Apache 2.0 makes that is easy to break by accident. No trademarks in the repo — logos are fetched at runtime, and public/logos/ is gitignored. No OpenStreetMap-derived coordinates, which is why every coastline in cities/sf.ts was traced by hand: Nominatim output is ODbL, share-alike, and would attach to the whole pack. And no FlightRadar24 client — their terms forbid scraping and redistribution, so flights are an interface with a simulator and open community ADS-B behind it. The privacy constraint and the licence constraint turned out to want the same thing. Geocoded company positions and pipeline status both stay behind Workie's API; the open repo holds the city and the renderer. The tempting shortcut — commit an sf-companies.json — breaks both at once. Ported out of Workie, where a 3D city engine had no business living. Workie's /live is deleted rather than deprecated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |