283 lines
14 KiB
Markdown
283 lines
14 KiB
Markdown
# 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.
|
||
|
||
```ts
|
||
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.
|
||
- Authenticated realtime is a route module in this same process, not another
|
||
daemon. Its state is bounded and memory-only. Event credentials travel in a
|
||
POST body, never a URL; the server owns interest filtering, motion validation,
|
||
token rotation and revocation. The auth subject is not a peer-visible entity
|
||
id, and no media is carried on the game-state stream.
|
||
|
||
### 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.
|