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/CONTRACT.md
T
karti 06455f7424 The office learns where it stands, and the sky stops being a backdrop
**Aircraft actually move now, and the reason they did not is the headline.**
`HttpFlights` holds a frozen snapshot between network refreshes and is
polled at 1 Hz, so a live feed handed the layer the same position five to
fifteen times and then jumped. `span` therefore measured the poll interval
rather than the gap between the two positions that differ, the teleport
test saw an airliner covering 36 units in a "second" against a ceiling of
8, and **every live track's history was wiped on every refresh** — so no
aircraft on the deployed site could ever grow a trail, however long
TRAIL_POINTS was set. Skipping the repeat fixes the motion and the trail
at once. Trails then go to 72 points / 240 s, which is about seventy
seconds of flying.

Three more defects in the same file, found while looking: trail
truncation dropped the segments nearest the aircraft (leaving a streak
with no aeroplane attached), MAX_TRACKS was declared and never enforced,
and one missing target deleted its whole trail. The buffer now uploads
only what it wrote, rather than 46 MB/s of untouched array.

**You can get above the constellation.** Dome to 1.05 board *radii* and
the orbit to 2.0 spans. Radii, not spans: scene space is centred on the
city and the Bay Area board runs forty kilometres down the peninsula, so
the furthest corner is 0.94 spans out where the half-diagonal is 0.65 —
sized off the half-diagonal the dome sat inside its own city. The far
plane goes to 4 spans to stop clipping the sky from off-centre chapters,
and `PointsMaterial` defaults `fog: true`, which was quietly dimming the
whole constellation with the city's haze.

**An office can say where it stands.** `Office.site` — lat, lng, height
above the ground outside, and the compass bearing the pack's −Z points
along — and with one it gets the same sun the city does, a sky, and a
horizon at `-elevation`. CONTRACT §4 reserved this as "a later
refinement"; it is taken up rather than overturned, and `daylight.ts`
computes no light of its own. It does the two things a room needs that a
map does not: turn the sun into the building's frame, and move the fog
outdoors before it greys out the far wall.

Two buildings now, and they are deliberately unalike: Lumbridge HQ 188 m
up a Transbay tower facing 205°, and **Frontier Valley**, a startup in a
hangar at Alameda Point — one room, 54 x 30 m, nine metres to the
trusses, four metres above reclaimed ground.

Floor-to-floor in the reference pack is now 16.8 m: the interstitial is
ten times a real one, so the space between the slabs is somewhere things
can hang. It is frankly not architecture, `PLENUM` is the one number to
change, and the file says so.

Also fixed, all found by review rather than by looking at the screen:

  - `sun.shadow.camera.updateProjectionMatrix()` was never called, so
    three's default ±5 unit box has been in force this whole time and
    every `shadowExtent` this repo passes — including the city's ±752 —
    has been silently ignored.
  - A missing aircraft was kept alive by the new grace period and *drawn*,
    so it froze in mid-air at full opacity for 32 s.
  - Frontier Valley's mezzanine was a `Room`, which carries no height: its
    slab lay on the concrete, its chairs floated 4.4 m over it, and its
    balustrade fenced off a patch of ground floor. It is a `Level`.
  - Overlapping floor slabs z-fought. The format permits overlap and
    resolves later-first, so `shell.ts` now lifts a slab a hair per
    earlier slab it overlaps — and by nothing at all in a pack, like the
    reference office, whose rooms only ever abut.
  - `switchOffice` bypassed the `entering` guard (leaking a whole scene
    per double-click) and tore down the old room before knowing the new
    one would load, with no way back.

Known and not fixed: raising MAX_SPAN to 30 s doubles the worst-case
re-base snap when a feed's gap shortens. It is bounded, pre-existing in
kind, and the fix wants carrying the live head into the next leg.

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

14 KiB
Raw Blame History

The contract

Five designs were produced in parallel and three critics tore into them. They collided on fifteen blocking points — four files were specified twice with incompatible contents, three separate backends were designed for one box, and one type name was exported twice meaning different things.

This file is the resolution. It wins over any individual design. Anything implemented here must match this document; where a design said otherwise, this document is why it changed.


0. Scope

We are building Lumbridge's own Tera and office, plus a dev kit so anyone can run their own on their own hardware.

Not in scope, deliberately: renting or purchasing parcels, billing, per-tenant provisioning, cluster placement, a membership/tenancy system. Federation — a self-hosted office announcing itself to a shared Tera — is a maybe-later that the adapter boundary leaves room for and nothing designs toward now.

The acceptance test for every decision below, and the one that failed end-to-end in the design round:

A stranger clones the repo, runs one command, gets a city, copies the reference office pack, and has their own office — with no Lumbridge account, no Supabase project, and no keys.

Two CI jobs make that testable rather than aspirational, and they gate the repo: git clone && npm ci && npm run build, and docker compose up under env -i asserting GET /api/v1/health → 200.


1. Stage and scene lifecycle

Two designs both created src/engine/stage.ts with opposite lifecycles. The retention argument wins on measured cost: the Bay Area's heightfield is 0.53M lattice points and about 2.3 s to build — it was 336,864 and ~1.0 s when the pack was San Francisco alone, and it grew, which is the point. Disposing the city every time someone steps into an office and paying a second of rebuild on the way out is not acceptable.

  • Stage (src/engine/stage.ts) owns only the renderer, the RAF loop, resize, and a swappable current scene. Nothing else.
  • StageScene carries its own THREE.Scene, PerspectiveCamera and OrbitControls. stage.setScene(s) swaps; the outgoing scene is retained and paused, never disposed.
  • Camera, controls, lights and picking live in a per-scene SceneKit helper that both createScene (city) and createOfficeScene construct. They do not live on Stage.

Rationale for the split: the city and an office cannot share a THREE.Scene at all. SF's latScale puts one scene unit at ~94 m with 3.6× vertical exaggeration; an office renders at 1 unit = 1 m. Two scenes, one renderer.

export interface StageScene {
  scene: THREE.Scene
  camera: THREE.PerspectiveCamera
  controls: OrbitControls
  onEnter?(): void
  onExit?(): void
  tick(dt: number, elapsed: number): void
  dispose(): void
}
export interface Stage {
  renderer: THREE.WebGLRenderer
  setScene(s: StageScene): void
  current(): StageScene | null
  dispose(): void
}

2. The office data contract — one file, one type

src/interiors/plan.ts was specified twice with incompatible contents, and a fourth design imported an OfficeDoc no design produced.

  • src/interiors/types.ts is the single authored contract. It exports Office, and must stay JSON-serialisable — no functions, no classes, no THREE types — because a hand-written pack and one arriving over HTTP have to be the same thing.
  • Walls are an explicit segment list with 1-D openings. Room polygons imply no walls; they are floor slabs. This wins because the opening-splitting pass produces the walk-mode collision segments for free, and because auto-generating walls from shared room edges needs float-equality dedup.
  • Doors and windows are Opening records punched out of a wall, not placeable assets. shell.wall.ts, shell.door.ts and shell.window.ts are deleted from the asset library; shell.ts calls a parameterised wallRun part per solid run. Shipping both would put every opening in the scene twice, or leave the collider with no gaps.
  • src/interiors/plan.ts is the Plan class — the World analogue. Resolves an Office once into wall runs, seats, props, collision segments and bounds. The other design's Placement[] is Plan's output, an internal build input, not a second authored format.
  • OfficeDoc = { id, name, floor: Office, … } lives in src/server/wire.ts, not in interiors.

3. Assets

  • Procedural TypeScript only. Every mesh is a function composing cached unit primitives; every texture is drawn on a 2D canvas from seeded noise. No binary art is ever committed. This is the same property that gives the city zero asset-licensing exposure, and it is worth more than the quality ceiling it costs.
  • The assets registry wins over the interiors one — it is strictly richer (roles, quality, overrides, footprint-without-build). src/assets/kit.ts defines AssetDef and AssetId; src/assets/materials.ts defines MaterialRegistry keyed on a closed SurfaceRole union.
  • Prop.kind is an AssetId. The prop registry and the asset registry are the same registry.
  • Namespace is tera: (tera:desk.workstation), not lse:. A self-hoster registers acme:desk.standing with overrides: "tera:desk.workstation" and reskins without forking.
  • Assets are authored in metres, 1 unit = 1 m.
  • ghostOf() moves onto the assets MaterialRegistry for the wall-occlusion fade.

3.1 The art licence lands with the first asset file

Apache 2.0 covers the code. The artistic output is additionally dedicated under CC0-1.0, so a mesh can leave this repo without dragging a NOTICE obligation into someone else's project.

This is decided now, not later, and src/assets/LICENSE-ART plus the CONTRIBUTING.md inbound terms land in the same commit as the first file under src/assets/. Deferring it is the expensive mistake: eighteen asset builders and a ~180-prop reference office is a lot of authored work to accumulate before anyone states the terms, and relicensing art once contributors exist is close to impossible.

The inbound grant must be standing, not per-PR. Apache 2.0 §5 supplies a default inbound=outbound grant for Apache-2.0 only; there is no default inbound CC0, so a single merged PR whose author never said the words leaves that contribution Apache-only and makes a directory-wide claim false. CONTRIBUTING.md therefore carries a DCO-style sentence — by submitting a change under src/assets/, you license it under Apache-2.0 and dedicate the artistic output under CC0-1.0 — so submission itself is the grant. LICENSE-ART is worded as a dedication made by the copyright holders of the material, not as a property of the directory. NOTICE gains the carve-out, because NOTICE is what a downstream consumer actually reads to learn the repo is not uniformly Apache-2.0.

4. Lighting and environment — one owner, one direction

Environment was exported twice meaning different things, and two modules both constructed and mutated the same three lights.

  • Environment is an observation: { time, sun: SolarPosition, weather }.

  • The lighting state is renamed LightingState.

  • Atmosphere is the sole light owner. atmosphere.apply(env) → LightingState, which the scene applies. One direction, no write-backs.

  • Solar position is computed locally with no network — a NOAA/Meeus implementation in src/engine/solar.ts, dependency-free.

  • An office with no site gets no Atmosphere: fog: null, no scene.background drive, interior lighting is its own fixed rig. This is still the default and still the promise — a pack can be authored, rendered and shared without owning a coordinate, an account or a network.

  • An office that declares a site gets the same sun the city does. This is the "later refinement" this clause reserved, taken up rather than a reversal of it: Atmosphere is still the sole light owner and there is still one direction of flow. interiors/daylight.ts adapts what apply() returned; it computes no light of its own.

    Two things are true only indoors, and they are the whole of the adapter:

    • The building is rotated. Atmosphere works in the city's frame, where Z is north because a city pack is a map. OfficeSite.heading is the bearing the pack's Z actually points along, and the sun is turned by it — otherwise "the daylight side" in a pack's comments is a label rather than a fact.
    • The fog starts outside. A city fog beginning 1,150 units away is fine at 94 m per unit and is inside the room at 1 m per unit. The colour is kept and the distances are replaced.

    A sited office also gets a sky and a ground plane at -site.elevation, which is what makes 188 m up a tower feel different from 4 m above an airfield.

5. One server

Three backends were designed for one box — three ports, three frameworks, three deploy/Caddyfile.snippet files that would overwrite each other.

  • One Fastify workspace, server/, listening on 127.0.0.1:8431, serving /api/v1/*. One systemd unit, one Caddy snippet.
  • Weather and office routes fold in as route modules, not services.
  • Env prefix is TERA_* throughout.
  • The Workie sync oneshot stays the only second process — it is the sole holder of a Workie credential, and that isolation earns itself.
  • Private per-user markers are never proxied. The authenticated browser calls Workie directly with its own token, so private rows never transit the public box.
  • Cache-Control is fail-closed: a global hook stamps private, no-store, and a route opts in to public caching explicitly.

5.1 Zero-config boot must actually boot

Both server designs made the same independent mistake: a weather source defaulting to a provider that requires a contact string, with a hard failure when it is absent — which fails the very acceptance test they named.

  • TERA_WEATHER_SOURCE defaults to none.
  • A source set without a contact is a demotion, not a fatality: log one loud line and serve the synthetic: true clear-day body.
  • The env -i CI job is what keeps this honest.

5.2 Weather sources

api.weather.gov (NWS) is US-government public domain, keyless, and the default once a contact is configured. met.no is the global fallback. Open-Meteo is opt-in and off by default: its data is CC-BY 4.0 but its free tier is non-commercial, which is the wrong default for a product page.

6. Auth

Scope-corrected: no membership tables, no tenancy.

  • TERA_AUTH_MODE defaults to none. A self-hoster gets an open office and never creates an account anywhere.
  • Lumbridge's own office uses sso mode, reusing the pattern already running on the fleet: the world holds no credentials, is handed an entry URL and a server-side revalidate URL, and enforcement happens on the server. Both are env vars, which is exactly what a dev kit needs.
  • Where a JWT is verified directly, HS256 against a shared secret is primary; JWKS sits behind an env switch. This is a correction from verified fact — the fleet's Supabase issues {"alg":"HS256"}, so a JWKS-only implementation would reject every real token.
  • A private office returns 404, not 403, so the endpoint cannot be used to enumerate what exists.
  • @supabase/supabase-js is a real dependency, and the "no surprise dependencies" CI check becomes an allowlist naming why each is permitted, not a count. A dynamic import of an uninstalled package fails the Vite build, which would have made the auth: none default — the committed default — unbuildable.

7. The binary gate must not fire on the self-hoster

The no-binary-art check is a promise about this repo's committed art, but as designed it walked the working tree and would fail a self-hoster's build on their own legally-clean .glb — while two other designs told them to put files exactly there.

  • Enumerate via git ls-files, never a filesystem walk, so untracked local assets are invisible to it.
  • Strict over src/** — that is where the licensing argument lives.
  • Hard-exempt public/props/, public/kits/, public/offices/, docs/, and add them to .gitignore marked as self-hoster space.

8. Geocoding — the correction that matters most

ARCHITECTURE.md §3.2 argued that keeping geocoded company coordinates out of the repo solved the ODbL problem. That reasoning is wrong, and this is the sharpest thing the critics found.

Containment solves licence mixing inside the repo. It does not touch ODbL's actual trigger. Serving a snapshot of OSM-derived coordinates at a public endpoint is Publicly Using a Derivative Database, which brings ODbL §4.3 attribution and §4.4 share-alike onto the served data regardless of where the rows are stored. Storing them off-repo hides the obligation; it does not discharge it.

Nothing is committed to yet — Workie has no geocoding code today — so:

  • The geocoder is the US Census Geocoder (geocoding.geo.census.gov): a US Government work in the public domain, keyless, and covering SF, LA and NYC, which is every city planned.
  • Google, Mapbox and HERE do not solve this either — their terms restrict storing and redistributing returned coordinates, which is precisely what a public snapshot does.
  • The sync script records a per-row provenance field, and the public-shape assertion rejects any row whose provenance is not on a non-ODbL allowlist. The gate that already checks field names now also checks where a coordinate came from.
  • NOTICE's GEOGRAPHIC DATA block gains this next to the existing USGS/SRTM sentence, and ARCHITECTURE.md §3.2's reasoning is corrected rather than quietly left standing.