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>
This commit is contained in:
+257
@@ -0,0 +1,257 @@
|
||||
# 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: SF's heightfield is 484 × 696 =
|
||||
336,864 lattice points and `world.ts` records a ~1.0 s build. 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 gets no Atmosphere**: `fog: null`, no `scene.background` drive,
|
||||
interior lighting is its own fixed rig. Daylight through windows is a later
|
||||
refinement, not a v1 coupling.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user