tower / commit
feat(app)!: the world is changed in the repository, not in the browser
.gitignore | 6 - README.md | 135 +++-- decisions.md | 48 ++ design.md | 101 ++-- docs/screenshots/desktop-detail.png | Bin 0 -> 1250682 bytes docs/screenshots/desktop-reader.png | Bin 1149474 -> 0 bytes index.html | 138 +---- src/main.ts | 22 +- src/style.css | 23 +- src/ui/app.ts | 842 +++----------------------- src/views/map.ts | 60 +- src/world/schema.ts | 20 +- src/world/store.ts | 226 ------- static/world.json | 1140 ----------------------------------- test/world.test.ts | 149 +---- tools/screenshots.mjs | 9 +- vite.config.ts | 38 +- world-generation.md | 72 ++- 18 files changed, 372 insertions(+), 2657 deletions(-) diff --git a/.gitignore b/.gitignore index 5444894..3facc40 100644 --- a/.gitignore +++ b/.gitignore @@ -3,12 +3,6 @@ dist/ .DS_Store *.local -# Author's private world data. world.private.json carries unpublished working -# notes and non-public places; it must never reach a public repository. -# See decisions.md D-011. -world.private.json -tower-backup-*.json - # Playwright verification screenshots .playwright-mcp/ diff --git a/README.md b/README.md index 8decb7e..7301248 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,17 @@ nothing passive would stay in the beam past mid-afternoon. It is a person. ## Status **All three views are built** — map, exterior and interior — over a world that -**generates itself live, in the browser**, from a file of authored features, and a -working writer mode. +**generates itself live, in the browser**, from a file of authored features. Built: the unrolled surface map, Tower from outside, standing inside it and flying -over it, feature editing for fourteen kinds (points, lines, polygons and bands), -localStorage autosave, backup and static exports, import, permalinks, and the -sightline probe. +over it, a detail card for every place, Tower's published weather, permalinks, and +the sightline probe. + +**The app is for looking; the world is changed in the repository.** The author writes +alongside Tower rather than in it, and changes the world by talking it through with +Claude, who edits [`content/world.json`](content/world.json). That file is the one +authoritative copy — there is no editor in the browser, no local draft, and nothing to +import or export (decisions.md D-072). **In progress: [world-generation.md](world-generation.md).** Phases 1 to 5 are built — the terrain is no longer baked into images by a build step; every mountain, lake, @@ -106,7 +110,7 @@ another machine will show a large one-off divergence rather than a regression. | [tour-girdle](docs/screenshots/desktop-tour-girdle.png) | scenic tour: nine kilometres over the Girdle | | [interior-overhead](docs/screenshots/desktop-interior-overhead.png) | looking up: the far side 100 km overhead is clearer than ground 100 km away | | [interior-dawn](docs/screenshots/desktop-interior-dawn.png) | dawn — a lit sky over dark ground, the light still 27 km away | -| [reader](docs/screenshots/desktop-reader.png) | `?reader=1` — terrain, features, a detail card and the probe, no editing | +| [detail](docs/screenshots/desktop-detail.png) | a place selected: its detail card, with what the world made of it | | Mobile — 390×844 | | |---|---| @@ -148,7 +152,7 @@ regressions, not things that were already broken. ``` npm install npm run dev # http://localhost:5173, plus a LAN URL and a QR code for it -npm test # 189 tests; canon figures are pinned to design.md +npm test # 175 tests; canon figures are pinned to design.md npm run build npm run screenshots # drive the app and rephotograph docs/screenshots/ npm run screenshots -- --only tour-storm # just those shots, for working on one view @@ -162,7 +166,8 @@ network while it runs; drop the `--host` if that is not wanted. Vite + TypeScript, deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch. Three.js is loaded only when a 3D view is opened, so a reader who stays on the map never downloads it. There is no terrain build step: the world is -generated in the browser from `static/world.json` (world-generation.md §5). +generated in the browser from `world.json`, which the build makes from +`content/world.json` (world-generation.md §5). Assets are referenced **relatively**, so the same build works whether Pages serves it from `https://pavelsafronov.gitlab.io/tower/` or from a generated unique domain @@ -208,8 +213,11 @@ lakes, seas, rivers, desert, forest, settlements and plain markers — with its canon coordinates (θ, z) and its parameters beside it, plus the author's notes. The browser generates the terrain from it live (world-generation.md, decisions.md D-062); there is no build step and there are no terrain images. -[`static/world.json`](static/world.json) is the same world as readers get it, with every -note stripped — the **Export static map content** button writes it. +It is the **only copy**: what the app loads is `world.json`, made from it by +[`vite.config.ts`](vite.config.ts) — served by the dev server, written into the build — +with every note removed and any feature marked `"public": false` left out. It is never +committed, so it cannot drift from the file it comes from. Save `content/world.json` +with `npm run dev` running and the open page reloads with the change. Generation is deterministic. The seeds are in the file, so every reader sees the same world the author does, to the tree. @@ -226,14 +234,17 @@ The world was ported from v1's `content/geography.json` by [`tools/port-world.mjs`](tools/port-world.mjs), kept for reading; there is no backwards compatibility with v1 files. -## Reading and writing +## Reading + +The app only reads the world. Select a place — in the list, on the map, or in the +interior — to see its detail card: its name, its blurb, where it is, and what the world +made of it — a range's named summits, the sea running on a lake or sea *now* by the +clock, how many trees a forest holds and how far apart, what a desert's landforms came +to. **Shift-click two points** on the map to measure a sightline (Esc clears it), and +**F** fits the map. -The map opens in **writer mode**: tools, editor, canon controls and import/export. -Add **`?reader=1`** to preview what a reader gets — the terrain, the features, a -detail card and the sightline probe, with no editing. Writer is the default while -the world is still being built; that is a one-line change when it is ready to -publish. Either way it is a UI mode, not a security boundary — with no backend an -editor can only change their own copy (decisions.md D-011). +Changes to the world — a new place, a moved summit, a storm in the weather plan — are +made in `content/world.json`, not in the browser (D-072). Any selection updates the URL, so a link is always shareable: @@ -364,67 +375,57 @@ axis. It is by a wide margin the plainest sign of the spin anywhere in the view, it is only available at night, at the deepward end, looking the one direction that has ever had anything in it but Tower. -## Working in the map - -Open **Add**, pick a kind, and click to place it. **Mountains**, **Lake**, **Sea**, -**Desert**, **Forest** and **Region** trace an outline; **River**, **Road** and -**Path** trace an open line — a river source first (Enter finishes, Esc cancels). -The rest are a single click. The ground regenerates as soon as the feature exists. -Drag markers to move them, **shift-click two points** to measure a sightline, **f** -to fit the view. - -Select a **mountain range** to edit its summits: each has a name and an elevation it -is held at exactly, **Add summit** places another with a click on the map, and a -summit's triangle can be dragged to move it. The range's other controls set how high -its unnamed summits stand and how many there are, how rugged it is, how deeply water -cuts it, and its snowline. A change regenerates the range — a few seconds for the -Girdle, under the status dialog — and edits made meanwhile are folded into the next. - -Select a **lake or sea** to set its wind: the direction it blows toward and its speed. -That is its usual wind, which blows whenever the weather plan has nothing scheduled. The -editor says what sea is running there *now*, by the clock — a 10 m/s wind over The Deep +## What the world file says + +Each feature in `content/world.json` has a kind, a shape — a point, an open line (a +river runs source first), an outline, or a band ringing the world — and its kind's +parameters beside it. Changing any of them is a conversation and an edit to the file; +the ground regenerates on the next load. This is what the parameters mean. + +A **mountain range** has named **summits**, each held at exactly its elevation, and +parameters for how high its unnamed summits stand and how many there are, how rugged it +is, how deeply water cuts it, and its snowline. A change regenerates the range — a few +seconds for the Girdle, under the status dialog. + +A **lake or sea** has a **wind**: the direction it blows toward and its speed. That is +its usual wind, which blows whenever the weather plan has nothing scheduled. The detail +card says what sea is running there *now*, by the clock — a 10 m/s wind over The Deep makes waves under a metre; the same wind round a ring sea, which never runs out of water to blow over, makes 2 m, and 20 m/s makes 8.6. A sea also has a number of ships. -Open **Weather** to see Tower's plan: what is blowing now and the next fortnight, with any -revision listed under the event it changed and the original struck through. Readers see -only what has been published. As the writer you can **schedule** weather over a lake or -sea (it is published the moment you add it), **revise** an event from now with a reason — -the storm eased because someone foolish went sailing — delete one, and set how many days -the plan takes to repeat. None of this regenerates the ground. - -Select a **settlement** to set its size — hamlet, village, town, city or station — and how -far it reaches across. A settlement is a point with an extent, not a traced outline: Tower -lays the streets out inside it. The Capital is 20 km across, centred on the axial midpoint -of the world, which is just wide enough to put its spinward edge on The Deep's shore. - -Select a **forest** to set what Tower planted there: stems per hectare, how much of it is -conifer, how much of the ground is left open as meadows, the treeline, and whether it is a -wood of giants. The hint says what that comes to — how many trees, over how much ground, +**Weather** is Tower's plan: events over a lake or sea, each published at a moment and +revisable later with a reason — the storm eased because someone foolish went sailing — +and a number of days the plan takes to repeat. The panel's **Weather** section shows what +is blowing now and the next fortnight, as far as it has been published, with any revision +listed under the event it changed and the original struck through. None of it +regenerates the ground. + +A **settlement** has a size — hamlet, village, town, city or station — and how far it +reaches. It is a point with an extent, not a traced outline: Tower lays the streets out +inside it. The Capital is 20 km across, centred on the axial midpoint of the world, which +is just wide enough to put its spinward edge on The Deep's shore. + +A **forest** has what Tower planted there: stems per hectare, how much of it is conifer, +how much of the ground is left open as meadows, the treeline, and whether it is a wood of +giants. The detail card says what that comes to — how many trees, over how much ground, how far apart, and how high the canopy stands. -Select the **desert** to lay out its landforms: how high its dunes stand, how far apart, -and which way Tower blows the sand; how much of it is rocky hills; how tall its plateaus -are, how much of the ground they cover, and how many cliff bands they are cut into; and how -much past water Tower quotes there — dry washes, how many salt pans, and whether they are -ringed by the shorelines of lakes that never stood. The hint underneath says what was -actually made: "24% standing rock, 37% dune field, 51% cut by washes, 4 salt pans." +A **desert** has its landforms: how high its dunes stand, how far apart, and which way +Tower blows the sand; how much of it is rocky hills; how tall its plateaus are, how much +of the ground they cover, and how many cliff bands they are cut into; and how much past +water Tower quotes there — dry washes, how many salt pans, and whether they are ringed by +the shorelines of lakes that never stood. The detail card says what was actually made: +"24% standing rock, 37% dune field, 35% cut by washes, 4 salt pans." -A **Region** is a label, and it can also be ground Tower raised: give it an uplift and +A **region** is a label, and it can also be ground Tower raised: give it an uplift and everything inside it is lifted to that height above the open ground, wherever that is higher than what is there. The Uplands are one. -Changing a feature's **Kind** swaps its parameters for the new kind's defaults and -keeps its name, notes, shape and seed; only kinds that can take its shape are offered. +Every feature also has a **blurb**, shown to readers; **notes**, which are the author's +and never reach the app — though the repository is public, so they are not secret; **`public`**, false for a feature not ready to exist in the app +at all; and a **seed**, written once, so it generates the same forever. Canon is **0 star slats** — the hull is sealed, the inner surface is one unbroken 169,646 km², and the only views out are at the end caps. The runtime control for this is disabled and hidden (decisions.md D-033): the world is generated as one unbroken surface, so any other count draws bands over land that has none. - -Everything autosaves to this browser's localStorage. Two exports: - -| | | -|---|---| -| **Export backup** | everything, private notes included. Keep out of the public repo — `.gitignore` already excludes `tower-backup-*.json`. | -| **Export static map content** | public features only, every `notes` stripped. This is `static/world.json`, the world readers see, and the seed for a browser with no local draft. | diff --git a/decisions.md b/decisions.md index 4e746ed..990372c 100644 --- a/decisions.md +++ b/decisions.md @@ -270,6 +270,10 @@ need answering to proceed. **Decided.** Two modes over one committed `world.json`. Readers get a finished map; the author gets editing plus a set of measuring instruments. No backend. +*Superseded in part by D-072: there is one mode now, and the world is edited in +`content/world.json` rather than in the browser. "The world file is git" and "no +backend" stand.* + **Origin.** The author intends to use the app to *write* — placing things and asking questions like "can the far face be seen from the midpoint?", "where does a central embassy go?", "can a lake overhang?". That makes measurement, not rendering, the @@ -316,6 +320,10 @@ already asking questions only it can answer. **Decided.** A place's id is re-derived from its name while it still matches it, and freezes permanently once the author edits the id field. +*Retired by D-072 along with the editor. Ids are written by hand in the file now, and +the reason below — a permalink is worthless if the id churns — still governs changing +one.* + **The tension.** design.md §3.2 makes ids permalink targets, which argues for never changing them. But places are created from a placeholder name ("New city"), so a never-changing id produces a world file of `new-city`, `new-city-2`, @@ -2340,3 +2348,43 @@ for. author's rules — inside Greatwood, 15–50 km from the Capital, off the water and the rivers — it landed 17 km from the city and is now a feature in `content/world.json` like any other. Greatwood gives up its own ground wherever a smaller forest stands inside it. + +--- + +## D-072 — The world is changed in the repository, not in the browser + +**Decided** by the author, 2026-09-26. Built. + +**The author writes alongside the app, not with it.** The writer mode of D-011 assumed +the map would be the place the world was built: draw a lake, name it, export, commit. +In practice the author wants to build it by describing changes to Claude, who edits +`content/world.json` directly — the Tall Wood (D-071) was placed that way. The editor +had become a second, stale path to the same file, and a hazardous one: a browser's local +draft was preferred over the committed world, so a draft from last week could quietly +shadow everything added since. + +**So the app only reads.** Removed: writer and reader modes (`?reader=1` now does +nothing), the Add tools, the feature editor and every per-kind control, dragging +markers and summits, weather scheduling and revision, the localStorage draft and its +autosave, backup and static exports, import, and "Reload published world". Everyone +sees what readers always saw — the three views, the list, a detail card, the published +weather board and the sightline probe — and the editor's read-only readouts moved onto +the detail card: a range's summits, a lake's or sea's waves now, a forest's tree count +and spacing, and what a desert's landforms came to. + +**`content/world.json` is the one authoritative copy.** `static/world.json` is gone: +the public file is made from `content/world.json` by a Vite plugin — served fresh by the +dev server, emitted into the build — with notes emptied and unpublished features left +out, exactly as `toPublicWorld` always did. Two committed copies had to be kept in step +by hand and a test; one copy and a derived file cannot disagree. Saving the file with +the dev server running reloads the page, so a change can be looked at as soon as it is +made. + +**What stays true from D-011:** no backend; the world lives in git beside the prose, +diffable and versioned; `notes` and `"public": false` keep things out of the app but +not out of the public repository. + +**A browser that used the old writer mode still holds its draft** under +`tower.world.v2` in localStorage. The app no longer reads or writes it, and does not +delete it either, so anything in it that never reached the repository can still be +recovered by hand. diff --git a/design.md b/design.md index 88ce1fd..b7ce3fa 100644 --- a/design.md +++ b/design.md @@ -712,27 +712,27 @@ src/ worker.ts one worker: compile, tiles, map rows service.ts the pool, generations, the status dialog's progress, ground queries views/ - palette.ts one colour per feature kind, shared by map, list and editor + palette.ts one colour per feature kind, shared by the map and the list terrain-lod.ts the ground as a quadtree of tiles, refined around the eye scenic.ts the scenic tour's camera moves, worked out from the world forest-view.ts the trees near the eye: meshes, then cards, then the canopy skin world/ schema.ts world.json v2 types + validation: features, not places - store.ts feature CRUD, localStorage drafts, JSON import/export instruments.ts sightline, viewshed, lighting probe, travel (§3.3) weather.ts Tower's weather plan read at an hour: what it does, what it told people state/ - store.ts selected point, time-of-day, spin phase, view mode, writer flag + store.ts selected point, time-of-day, spin phase, view mode views/ exterior.ts Three.js: hull, collector, beam, spin, point picker interior-model.ts ground tiles, caps, bore, beam and haze, shared by both 3D views map.ts Canvas 2D: unrolled surface, slats, terminator, point picker interior.ts Three.js: shell geometry, the haze shader of §1.5, beam, rain ui/ - app.ts the whole panel: tools, editor, readouts, view switching + app.ts the whole panel: list, detail card, weather board, readouts, view switching scales.ts slider mappings — kept out of views/ so the panel can label the eye-height slider without pulling in Three.js - main.ts + main.ts loads world.json and starts the app +vite.config.ts also makes world.json from content/world.json (§3.1) tools/ node-only, never bundled; plain .mjs with no build step port-world.mjs the one-off port of v1's geography.json into world.json v2 @@ -756,7 +756,6 @@ type TowerState = { spinPhase: number // 0..2pi, rotation for star motion spinning: boolean view: 'exterior' | 'map' | 'interior' - writer: boolean // true unless ?reader=1 -- a UI mode, not a security boundary probe: { from: SurfacePoint; to: SurfacePoint } | null } ``` @@ -961,34 +960,36 @@ blended under the far side's seas. --- -## 3. Writer mode and the world file +## 3. The world file, and how it changes The app is an instrument for writing the novel as much as a thing to look at. It must answer questions like *can the far face be seen from the midpoint?*, *where -does a central embassy go?*, *can a lake overhang?* — and let their answers be -placed, named and kept. +does a central embassy go?*, *can a lake overhang?* — and their answers are placed, +named and kept in the world file. -### 3.1 Two modes over one data file +### 3.1 One file, changed in the repository -| | Reader (`?reader=1`) | Writer (the default, for now) | -|---|---|---| -| World content | committed `world.json`, read-only | editable, drafts in `localStorage` | -| Instruments (§3.3) | sightline probe only | all | -| Output | permalinks to a point or place | **Export JSON** for the author to commit | -| Panel | read-only detail card | full editor, canon controls, import/export | +**`content/world.json` is the world, and the only copy of it** (D-072). The author +writes alongside Tower rather than in it, and changes the world by talking it over with +Claude, who edits the file; git keeps its history beside the prose. There is no editor +in the browser, no local draft in `localStorage`, and no import or export. -There is no backend — GitLab Pages is static. The loop is: the author edits in the -browser, exports `world.json`, commits it, and the published site serves it. Simple, -versioned in git alongside the prose, and diffable. +| | | +|---|---| +| World content | `content/world.json`, committed; the app only reads it | +| What the app loads | `world.json`, made from it by `vite.config.ts`: every `notes` emptied, every feature with `"public": false` left out | +| Instruments (§3.3) | the sightline probe | +| Output | permalinks to a point or place | +| Panel | view controls, place list, a read-only detail card, the weather board | -**Writer mode is the default while the world is being built** — the app has always -shipped that way, and making readers the default is a one-line change when it is ready to -publish. §3.1 once said the opposite; see D-068. +The published `world.json` is **derived, never committed**: the dev server serves it +fresh on every request and reloads an open page when `content/world.json` is saved, and +the build writes it into `dist/`. There is no second copy to keep in step. There is +no backend — GitLab Pages is static — and none is needed: the loop is edit, commit, +push, and the published site serves it. -**Writer mode is a UI mode, not a security boundary.** Anyone can use it; they can only -edit their own local copy, which persists nowhere and reaches nobody. -This is fine, and arguably good — a reader who finds it gets a sandbox. It must not -be mistaken for access control. +Everyone sees the same app. Until D-072 there was a writer mode (the default) and a +reader mode (`?reader=1`); the query parameter now does nothing. ### 3.2 The world file @@ -1030,42 +1031,34 @@ world-generation.md adds a parameter only when a generator reads it. v1 files, w `places`, load as empty — there is no backwards compatibility (D-062). **Two text fields, deliberately separate.** `blurb` is written for readers and -ships; `notes` is the author's working thinking and never leaves the machine except -in a backup. One field with a flag would have made every note a publication -accident waiting to happen. +ships; `notes` is the author's working thinking and never reaches the app. One field +with a flag would have made every note a publication accident waiting to happen. -**Ids follow the name until pinned.** An id is a permalink target and must be -stable once published, but during world-building names churn constantly and a file -full of `new-city-7` is unreadable. So the id tracks the name while it still -matches it, and editing the id field freezes it for good. The editor shows the id -for exactly this reason. +**Ids are permalink targets.** Once a place has been linked to, its id should not +change even if its name does; a river names what it flows into by id, so renaming an +id means rewriting those references too. Coordinates are canon (θ radians, z metres, §1.7) — never map pixels, so the file survives any change to how the map is drawn. **The `public` flag is a privacy trap worth naming.** Working notes ("embassy here? -check sightline to the cap") must not ship to readers. But a `world.json` committed -to a public repository is public regardless of any flag in it. So export writes two -files: **`world.json`** (public entries only, committed) and -**`world.private.json`** (everything, gitignored by default). Whether private notes -should nonetheless be version-controlled — in a private repo, or a second one — is -the author's call and is deliberately unresolved. - -**`content/world.json` is the whole world, notes included, and it is committed** — -exactly as `content/geography.json` was before it, which carried the same notes. It -is not served (Vite serves `static/`), but the repository is public, so anything in its -notes is public too. Nothing about v2 changed that; it is recorded here so the move -from one file to the other is not mistaken for a change in what is private. +check sightline to the cap") are kept out of the app, and a `"public": false` feature +is left out of it entirely. But **`content/world.json` is committed to a public +repository**, notes and unpublished features included — exactly as +`content/geography.json` was before it — so anything in it is public to anyone who +reads the source. The flag keeps things out of the map, not out of sight. Whether +private notes should live somewhere else — a private repo, or a second one — is the +author's call and is deliberately unresolved. ### 3.3 Instruments -The measuring tools are the real substance of writer mode. Each answers a question -the novel actually raised: +The measuring tools answer questions the novel actually raised. Only the sightline +probe is built; the rest are planned: - **Sightline probe.** Pick two points; get surface distance, straight-line distance, optical depth, transmission, and a plain verdict (*crisp / hazy / ghost - / invisible*). This is the tool that answered §1.8. Available to readers too — it - is the single most illustrative thing in the app. + / invisible*). This is the tool that answered §1.8, and the single most + illustrative thing in the app. - **Viewshed overlay.** From a selected point, shade the whole map by transmission. It renders the axis-versus-overhead asymmetry as one picture, which is worth more than the table in §1.8. @@ -1081,7 +1074,8 @@ the novel actually raised: The public link is the finished map: embassies, towns, lakes, named regions — the permanent things, behaving like any map application. Plus the exterior view, the interior view from any point, and permalinks so a reader can be handed a place and -a moment. Reading the world, not editing it. +a moment. Reading the world, not editing it — which, since D-072, is what the author +gets too. ## 4. Phases @@ -1095,7 +1089,10 @@ a moment. Reading the world, not editing it. world needs developing before there is anything to render.* Map view, place editing for all thirteen kinds, points/polylines/polygons, localStorage autosave, backup and static exports, import, and the sightline probe. -7. **Content pipeline and reader mode** — ✅ reader and writer modes (D-011); + *Removed by D-072: the world is now changed in `content/world.json`, and only + the sightline probe remains.* +7. **Content pipeline and reader mode** — ✅ reader and writer modes (D-011, one + mode since D-072); permalinks (D-010). The terrain pipeline of D-019 — `content/geography.json` and `npm run terrain` — is replaced by live generation (below). 8. **Polish** — mobile ✅ (the sidebar is a drawer below 720 px, D-043) and a diff --git a/docs/screenshots/desktop-detail.png b/docs/screenshots/desktop-detail.png new file mode 100644 index 0000000..17d3746 Binary files /dev/null and b/docs/screenshots/desktop-detail.png differ diff --git a/docs/screenshots/desktop-reader.png b/docs/screenshots/desktop-reader.png deleted file mode 100644 index 7aa838e..0000000 Binary files a/docs/screenshots/desktop-reader.png and /dev/null differ diff --git a/index.html b/index.html index 7b01b52..2e45593 100644 --- a/index.html +++ b/index.html @@ -34,7 +34,6 @@ <aside id="panel"> <header> <h1>Tower</h1> - <span id="mode" class="pill"></span> <button id="panel-close" type="button">Close</button> </header> @@ -45,6 +44,8 @@ <p class="hint">Generated live from the world's features, in your browser. On the exterior it is wrapped onto the hull — an x-ray of the inner surface, not what Tower looks like from outside.</p> + <p class="hint">Click a place to select it. <b>Shift-click two points</b> to + measure a sightline between them; Esc clears it. <b>F</b> fits the map.</p> </div> <div id="exterior-options" class="hidden"> <label>Hull @@ -102,37 +103,6 @@ them, and changes them when it sees fit. Times are world time, counted from day 0; run the clock in the interior view to move through them.</p> <ul id="weather-board" class="board"></ul> - <div id="weather-edit"> - <p class="sub" id="w-title">Schedule weather</p> - <label>Over <select id="w-target"></select></label> - <label>Kind <select id="w-kind"> - <option value="calm">Calm</option> - <option value="breeze">Breeze</option> - <option value="gale">Gale</option> - <option value="storm" selected>Storm</option> - </select></label> - <div class="pair"> - <label>Day <input id="w-day" type="number" min="0" step="1" /></label> - <label>Hour <input id="w-hour" type="number" min="0" max="23.5" step="0.5" /></label> - </div> - <div class="pair"> - <label>Hours <input id="w-duration" type="number" min="0.5" step="0.5" value="12" /></label> - <label>Wind, m/s <input id="w-speed" type="number" min="0" max="60" step="1" value="28" /></label> - </div> - <label>Blowing toward, degrees <span class="dim">— 0 deepward, 90 spinward</span> - <input id="w-dir" type="number" min="0" max="359" step="15" value="90" /></label> - <label id="w-reason-row" hidden>Why Tower changed its mind <input id="w-reason" type="text" /></label> - <button id="w-submit" type="button">Publish now</button> - <button id="w-cancel" type="button" hidden>Cancel</button> - <label>The plan repeats every, days <span class="dim">— 0 for never</span> - <input id="w-cycle" type="number" min="0" step="1" /></label> - </div> - </details> - - <details class="sec" id="tools-section"> - <summary>Add</summary> - <div id="tools" class="tools"></div> - <p class="hint" id="tool-hint">Click a feature to select. Drag a marker to move it. Shift-click two points to measure a sightline.</p> </details> <details class="sec" id="world-section" open> @@ -145,109 +115,7 @@ <p id="d-name"></p> <p id="d-blurb" class="dim"></p> <p id="d-where" class="dim"></p> - </details> - - <details class="sec" id="editor" hidden open> - <summary>Selected</summary> - <label>Name <input id="f-name" type="text" /></label> - <label>Kind <select id="f-kind"></select></label> - <div id="f-mountains" hidden> - <p class="sub">Summits <span class="dim">— each stands at exactly its height; drag one on the map to move it</span></p> - <ul id="f-peaks" class="peaks"></ul> - <button id="f-add-peak" type="button">Add summit</button> - <p class="hint" id="f-add-peak-hint" hidden>Click the map to place the summit. Esc cancels.</p> - <label>Height of generated summits, m <input id="f-height" type="number" step="50" min="0" /></label> - <label>Generated summits <span id="f-gen-value" class="dim"></span> - <input id="f-gen" type="range" min="0" max="3" step="0.1" /></label> - <label>Ruggedness <span id="f-rugged-value" class="dim"></span> - <input id="f-rugged" type="range" min="0" max="1" step="0.05" /></label> - <label>Erosion <span id="f-erosion-value" class="dim"></span> - <input id="f-erosion" type="range" min="0" max="1" step="0.05" /></label> - <label>Snowline, m <input id="f-snow" type="number" step="50" /></label> - <p class="hint">Changing any of these regenerates the range: a few seconds for the Girdle.</p> - </div> - <div id="f-water" hidden> - <p class="sub">Wind <span class="dim">— the usual wind here, whenever Tower's weather plan has nothing else scheduled</span></p> - <label>Blowing toward, degrees <span class="dim">— 0 deepward, 90 spinward</span> - <input id="f-wind-dir" type="number" step="15" min="0" max="359" /></label> - <label>Speed <span id="f-wind-speed-value" class="dim"></span> - <input id="f-wind-speed" type="range" min="0" max="30" step="1" /></label> - <p class="hint" id="f-sea-state"></p> - </div> - <div id="f-settlement" hidden> - <label>Size <select id="f-size"> - <option value="hamlet">Hamlet</option> - <option value="village">Village</option> - <option value="town">Town</option> - <option value="city">City</option> - <option value="station">Station</option> - </select></label> - <label>Across, km <span class="dim">— Tower lays the streets out inside this</span> - <input id="f-extent" type="number" min="0.1" max="80" step="0.5" /></label> - </div> - <div id="f-forest" hidden> - <p class="sub">Trees <span class="dim">— what Tower planted here</span></p> - <div class="pair"> - <label>Stems per hectare <input id="f-density" type="number" min="1" max="2000" step="25" /></label> - <label>Conifer share <input id="f-conifer" type="number" min="0" max="1" step="0.05" /></label> - </div> - <div class="pair"> - <label>Meadows <input id="f-meadows" type="number" min="0" max="1" step="0.05" /></label> - <label>Treeline, m <input id="f-treeline" type="number" min="0" max="5000" step="100" /></label> - </div> - <label class="row"><input id="f-giants" type="checkbox" /> Giants — a tall wood of 90–115 m trees</label> - <p class="hint" id="f-forest-state"></p> - </div> - <div id="f-desert" hidden> - <p class="sub">Dunes <span class="dim">— crests run across the wind Tower sends here</span></p> - <div class="pair"> - <label>Height, m <input id="f-dune-height" type="number" min="0" max="300" step="5" /></label> - <label>Spacing, m <input id="f-dune-spacing" type="number" min="50" max="4000" step="50" /></label> - </div> - <div class="pair"> - <label>Blowing toward, ° <input id="f-dune-dir" type="number" min="0" max="359" step="15" /></label> - <label>Coverage <input id="f-dune-cover" type="number" min="0" max="1" step="0.05" /></label> - </div> - <p class="sub">Rock</p> - <div class="pair"> - <label>Hills, m <input id="f-hill-height" type="number" min="0" max="600" step="10" /></label> - <label>Hill coverage <input id="f-hill-cover" type="number" min="0" max="1" step="0.05" /></label> - </div> - <div class="pair"> - <label>Plateaus, m <input id="f-plat-height" type="number" min="0" max="1500" step="20" /></label> - <label>Coverage <input id="f-plat-cover" type="number" min="0" max="1" step="0.05" /></label> - </div> - <label>Terraces <span class="dim">— cliff bands between flat tops</span> - <input id="f-plat-terraces" type="number" min="1" max="8" step="1" /></label> - <p class="sub">Past water <span class="dim">— marks of water that never ran here</span></p> - <div class="pair"> - <label>Washes <input id="f-washes" type="number" min="0" max="1" step="0.1" /></label> - <label>Salt pans <input id="f-pans" type="number" min="0" max="12" step="1" /></label> - </div> - <label class="row"><input id="f-shorelines" type="checkbox" /> Shorelines around the pans</label> - <p class="hint" id="f-desert-state"></p> - </div> - <label>ID <span class="dim">— permalink target; follows the name until you edit it</span> - <input id="f-id" type="text" /></label> - <label>Public blurb <textarea id="f-blurb" rows="2" placeholder="Shown to readers."></textarea></label> - <label>Private notes <textarea id="f-notes" rows="3" placeholder="Never leaves your machine unless you export a backup."></textarea></label> - <label class="row"><input id="f-public" type="checkbox" /> Publish to readers</label> - <p class="dim" id="f-where"></p> - <button id="f-delete" class="danger">Delete</button> - </details> - - <details class="sec" id="data-section"> - <summary>Data</summary> - <div id="save" class="save"></div> - <button id="export-backup">Export backup <span class="dim">— everything</span></button> - <button id="export-static">Export static map content <span class="dim">— public only</span></button> - <button id="import">Import backup…</button> - <input id="import-file" type="file" accept="application/json" hidden /> - <button id="reseed">Reload published world</button> - <p class="hint"> - Backups carry your private notes; keep them out of a public repo. - The static export is <code>static/world.json</code> — the default readers see. - </p> + <p id="d-state" class="hint" hidden></p> </details> <details class="sec" id="about-section"> diff --git a/src/main.ts b/src/main.ts index 2b8cf4b..aabef1f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,25 @@ import './style.css' import { App } from './ui/app.js' -import { WorldStore } from './world/store.js' +import { type World, emptyWorld, parseWorld } from './world/schema.js' /** * The slat count stays at its canon value of 0. The runtime control is disabled and * its UI removed — see decisions.md D-033. Restoring a stored non-zero count here * would silently reintroduce the bug for anyone who had already moved the slider. */ -const store = new WorldStore() -// The committed public world seeds a browser with no local draft yet. -await store.load(`${import.meta.env.BASE_URL}world.json`) -new App(store) + +/** + * The world as readers get it, made from `content/world.json` at build time (D-072). + * The app only reads it: the world is changed in the repository, not in the browser. + */ +async function loadWorld(url: string): Promise<World> { + try { + const res = await fetch(url) + if (res.ok) return parseWorld(await res.json()) + } catch { + // Unreachable or unreadable: an empty world still shows the cylinder. + } + return emptyWorld() +} + +new App(await loadWorld(`${import.meta.env.BASE_URL}world.json`)) diff --git a/src/style.css b/src/style.css index d0ef367..2d45921 100644 --- a/src/style.css +++ b/src/style.css @@ -1,6 +1,6 @@ :root { --bg: #0b0d12; --panel: #131722; --line: #232a3a; - --fg: #dde3ee; --dim: #8892a6; --accent: #7fd4ff; --danger: #e06c6c; + --fg: #dde3ee; --dim: #8892a6; --accent: #7fd4ff; color-scheme: dark; } * { box-sizing: border-box; } @@ -85,14 +85,10 @@ details.sec[open] > summary { color: var(--fg); } details.sec[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); } details.sec > summary:hover { color: var(--fg); } details.sec > :not(summary):last-child { margin-bottom: 14px; } -.pill { font-size: 10px; padding: 2px 7px; border-radius: 999px; - background: #26314a; color: var(--accent); letter-spacing: 0.06em; } .dim { color: var(--dim); font-weight: 400; } .hint { color: var(--dim); font-size: 11px; margin: 8px 0 14px; } code { background: #0b0d12; padding: 1px 4px; border-radius: 3px; } -.tools { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; } -.tools button { text-align: center; } button { width: 100%; margin-bottom: 6px; padding: 7px 10px; border-radius: 6px; border: 1px solid var(--line); background: #1b2130; color: var(--fg); @@ -104,8 +100,6 @@ button:disabled { opacity: 0.45; cursor: default; } select:disabled, input:disabled { opacity: 0.45; cursor: default; } button:disabled:hover { background: #1b2130; } button.active { border-color: var(--accent); color: var(--accent); background: #16233a; } -button.danger { border-color: #4a2a2a; color: var(--danger); } -button.danger:hover { background: #2a1a1a; } .list { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow-y: auto; } .list li { display: flex; align-items: center; gap: 8px; padding: 5px 6px; @@ -115,37 +109,26 @@ button.danger:hover { background: #2a1a1a; } .list .swatch { width: 9px; height: 9px; border-radius: 50%; flex: none; } .list .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .list .kd { color: var(--dim); font-size: 10px; } -.list li.private .nm { opacity: 0.6; font-style: italic; } #detail p { margin: 0 0 8px; } #d-name { font-size: 14px; font-weight: 600; } label { display: block; margin-bottom: 9px; color: var(--dim); font-size: 11px; } label.row { display: flex; align-items: center; gap: 7px; color: var(--fg); font-size: 12px; } -input[type=text], input[type=number], select, textarea { +input[type=number], select { width: 100%; margin-top: 3px; padding: 6px 8px; border-radius: 5px; border: 1px solid var(--line); background: #0e1219; color: var(--fg); - font: inherit; font-size: 12px; resize: vertical; + font: inherit; font-size: 12px; } input[type=range] { width: 100%; margin-top: 6px; accent-color: var(--accent); } .warn { color: #e8c46a; } .links { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 0 0 14px; font-size: 11px; } .links a { color: var(--accent); text-decoration: none; } .links a:hover { text-decoration: underline; } -.save { font-size: 11px; color: var(--dim); margin-bottom: 10px; } -.sub { font-size: 11px; color: var(--dim); margin: 4px 0 6px; } -.peaks { list-style: none; margin: 0 0 8px; padding: 0; } -.peaks li { display: grid; grid-template-columns: 1fr 76px 26px; gap: 6px; margin-bottom: 6px; align-items: center; } -.peaks input { width: 100%; box-sizing: border-box; } -.peaks button { padding: 4px 0; } -.save.error { color: var(--danger); } -.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .board { list-style: none; margin: 0 0 10px; padding: 0; } .board li { padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 12px; } .board li.now { color: var(--accent); } .board .rev { color: var(--dim); font-size: 11px; margin-top: 2px; } .board s { color: var(--dim); } -.board .acts { display: flex; gap: 6px; margin-top: 4px; } -.board .acts button { width: auto; margin: 0; padding: 3px 10px; font-size: 11px; } /** * Narrow viewports: the sidebar becomes a drawer. diff --git a/src/ui/app.ts b/src/ui/app.ts index b0a54d0..0a44625 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -1,31 +1,24 @@ /** - * Writer-mode UI. See design.md §3. + * The panel and everything it drives. See design.md §3. * - * The loop this serves: place and name things on the map, autosaved to localStorage - * continuously; export a full backup whenever; export the static public content - * once to seed `static/world.json` in the repo. + * The app reads the world and never changes it: the world is `content/world.json` in + * the repository, edited there (D-072). What is here is looking — three views, a detail + * card, the weather board and the sightline probe. */ import { landStripWidth, slatCount, slatWidth } from '../canon/slats.js' import { type SurfacePoint, landStripIndex, isSlat, surfaceDistance } from '../canon/geometry.js' import { sightline } from '../world/instruments.js' -import { - type Desert as DesertFeature, type Feature, type FeatureKind, FEATURE_KINDS, anchorOf, - geometryTypesFor, isAreaKind, isAutoId, isTracedKind, makeId, newSeed, parseWorld, - SETTLEMENT_RADIUS_M, withKind, -} from '../world/schema.js' +import { type Feature, type World, anchorOf } from '../world/schema.js' import { terrain } from '../terrain/service.js' import { seaStateOf } from '../terrain/sea-state.js' -import { type WeatherEvent, type WeatherKind, WEATHER_KINDS } from '../world/schema.js' -import { type Occurrence, hoursOf, occurrences, timeOf } from '../world/weather.js' +import { hoursOf, occurrences, timeOf } from '../world/weather.js' import { KIND_COLOR } from '../views/palette.js' -import { type FeaturePatch, WorldStore, backupFilename, download } from '../world/store.js' import { TowerMap } from '../views/map.js' import type { ExteriorView } from '../views/exterior.js' import type { InteriorView, Lighting } from '../views/interior.js' import { CIRCUMFERENCE_M, G0, LENGTH_M, RADIUS_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js' import { eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js' -type Tool = 'select' | FeatureKind type ViewName = 'map' | 'exterior' | 'interior' const VIEW_KEY = 'tower.view' /** The exterior's hull reading, remembered across reloads like the view itself (D-061). */ @@ -110,16 +103,6 @@ export class App { private exteriorModule: Promise<typeof import('../views/exterior.js')> | null = null private interiorModule: Promise<typeof import('../views/interior.js')> | null = null private view: ViewName = 'map' - /** - * Writer mode is a UI mode, not a security boundary (D-011). It is guessable - * either way, and that is fine: with no backend an editor can only change the - * visitor's own copy. - * - * Default is WRITER for now — the world is still being built and the author is - * the only visitor. `?reader=1` previews what a reader sees. Flipping the - * default is a one-line change here when the map is ready to publish. - */ - private readonly writer = !new URLSearchParams(location.search).has('reader') /** * Below this width the sidebar is a drawer over the world rather than a column * beside it (see the media query in style.css). The panel needs to know, because @@ -127,28 +110,17 @@ export class App { * just moved, and only a drawer has anything to get out of the way of. */ private readonly narrow = matchMedia('(max-width: 720px)') - private tool: Tool = 'select' private selectedId: string | null = null - private draft: [number, number][] = [] - /** The range a summit is about to be added to by the next click on the map, if any. */ - private addingPeakTo: string | null = null private probeFrom: SurfacePoint | null = null - constructor(private store: WorldStore) { + constructor(private world: World) { this.map = new TowerMap($<HTMLCanvasElement>('map'), { onPick: (p, hit, shift) => this.onPick(p, hit, shift), - onDragPlace: (id, p) => this.onDragPlace(id, p), - onDragPeak: (id, index, p) => this.onDragPeak(id, index, p), onHover: (p) => this.onHover(p), }) this.buildViews() - this.buildTools() - this.buildKindSelect() - this.wireEditor() this.wireView() - this.wireData() this.wirePanel() - this.applyMode() window.addEventListener('hashchange', () => this.readHash()) window.addEventListener('resize', () => { this.map.resize() @@ -156,9 +128,6 @@ export class App { this.interior?.resize() }) window.addEventListener('keydown', (e) => this.onKey(e)) - // A close mid-debounce must not lose the last edit. - window.addEventListener('pagehide', () => this.store.flush()) - this.store.subscribe(() => this.render()) this.wireTerrain() this.map.fit() this.readHash() @@ -176,9 +145,9 @@ export class App { // --------------------------------------------------------------- terrain /** - * Keep the generated world in step with the stored one, and show the status dialog - * while it is being made. The service ignores changes that do not touch terrain, so - * this can run on every store change without regenerating on a rename. + * Hand the world to the terrain service, and show the status dialog while it is being + * made. The detail card's readouts are read off the compiled world, so they are + * redrawn once it exists. */ private wireTerrain(): void { const service = terrain() @@ -230,8 +199,12 @@ export class App { if (busy && label && label !== queue.at(-1)) queue.push(label) if (!timer) pump() }) - void service.setWorld(this.store.current) - this.store.subscribe(() => { void service.setWorld(this.store.current) }) + void service.setWorld(this.world) + service.onWorld(() => this.renderDetail()) + } + + private get(id: string): Feature | undefined { + return this.world.features.find((f) => f.id === id) } // ----------------------------------------------------------------- views @@ -431,7 +404,7 @@ export class App { $<HTMLSelectElement>('f-light').disabled = this.interior.cycling } this.interior.resize() - this.interior.setPlaces(this.store.features.filter((p) => p.geometry.type !== 'band')) + this.interior.setPlaces(this.world.features.filter((p) => p.geometry.type !== 'band')) this.interior.start() this.render() } @@ -466,17 +439,6 @@ export class App { $('panel').inert = this.narrow.matches && !open } - /** Readers get the map, the place list and the sightline probe — nothing else. */ - private applyMode(): void { - $('mode').textContent = this.writer ? 'writer' : 'reader' - for (const id of ['tools-section', 'data-section']) { - $(id).hidden = !this.writer - } - if (!this.writer) { - $('tool-hint').textContent = 'Shift-click two points to measure a sightline.' - } - } - // --------------------------------------------------------------- permalinks /** @@ -505,7 +467,7 @@ export class App { const view = params.get('view') if (isView(view) && view !== this.view) void this.setView(view) const id = params.get('place') - if (id && this.store.get(id)) { + if (id && this.get(id)) { this.selectedId = id this.focusOn(id) this.render() @@ -522,7 +484,7 @@ export class App { } private focusOn(id: string): void { - const p = this.store.get(id) + const p = this.get(id) if (!p) return const point = anchorOf(p) this.map.centreOn(point) @@ -536,65 +498,12 @@ export class App { } } - // ------------------------------------------------------------------- toolbar - - private buildTools(): void { - const host = $('tools') - const mk = (tool: Tool, label: string) => { - const b = document.createElement('button') - b.textContent = label - b.dataset['tool'] = tool - b.onclick = () => this.setTool(tool) - host.append(b) - return b - } - mk('select', 'Select') - for (const k of FEATURE_KINDS) mk(k, k[0]!.toUpperCase() + k.slice(1)) - } - - private setTool(tool: Tool): void { - this.tool = tool - this.draft = [] - this.map.draftPolygon = [] - this.probeFrom = null - this.map.probe = null - $('sightline').classList.add('hidden') - $('tool-hint').textContent = - tool === 'select' - ? 'Click a marker to select. Drag to move. Shift-click two points to measure a sightline.' - : isAreaKind(tool) - ? `Click to trace the outline of ${tool === 'mountains' ? 'a range' : `a ${tool}`}. Enter closes it; Esc cancels.` - : isTracedKind(tool) - ? `Click to trace the course of a ${tool}, ${tool === 'river' ? 'source first' : 'start first'}. Enter finishes it; Esc cancels.` - : `Click the map to place a ${tool}.` - this.render() - } - // -------------------------------------------------------------- map handlers private onPick(p: SurfacePoint, hit: Feature | null, shift: boolean): void { if (shift) return this.onProbe(p) - - if (this.addingPeakTo) { - const f = this.store.get(this.addingPeakTo) - this.setAddingPeak(null) - if (f?.kind !== 'mountains') return - this.store.update(f.id, { - peaks: [...f.peaks, { name: '', theta: p.theta, z: p.z, elevationM: f.heightM }], - }) - return - } - - if (this.tool === 'select') { - this.selectedId = hit?.id ?? null - return this.render() - } - if (isTracedKind(this.tool)) { - this.draft.push([p.theta, p.z]) - this.map.draftPolygon = this.draft - return this.render() - } - this.createFeature(this.tool, { type: 'point', theta: p.theta, z: p.z }) + this.selectedId = hit?.id ?? null + this.render() } private onProbe(p: SurfacePoint): void { @@ -624,25 +533,6 @@ export class App { ].join('\n') } - private onDragPlace(id: string, p: SurfacePoint): void { - const place = this.store.get(id) - if (place?.geometry.type !== 'point') return - this.store.update(id, { geometry: { type: 'point', theta: p.theta, z: p.z } }) - } - - private onDragPeak(id: string, index: number, p: SurfacePoint): void { - const f = this.store.get(id) - if (f?.kind !== 'mountains' || !f.peaks[index]) return - const peaks = f.peaks.map((pk, i) => (i === index ? { ...pk, theta: p.theta, z: p.z } : pk)) - this.store.update(id, { peaks }) - } - - private setAddingPeak(id: string | null): void { - this.addingPeakTo = id - $('f-add-peak-hint').hidden = id === null - $<HTMLButtonElement>('f-add-peak').classList.toggle('active', id !== null) - } - private onHover(p: SurfacePoint | null): void { const el = $('cursor') if (!p) { @@ -661,48 +551,17 @@ export class App { private onKey(e: KeyboardEvent): void { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + // Esc drops a half-measured or finished sightline. if (e.key === 'Escape') { - this.setAddingPeak(null) - return this.setTool('select') - } - if (e.key === 'Enter' && isTracedKind(this.tool as FeatureKind)) { - const area = isAreaKind(this.tool as FeatureKind) - if (this.draft.length >= (area ? 3 : 2)) { - this.createFeature(this.tool as FeatureKind, - area ? { type: 'polygon', points: this.draft } : { type: 'polyline', points: this.draft }) - this.draft = [] - this.map.draftPolygon = [] - } - } - if ((e.key === 'Backspace' || e.key === 'Delete') && this.selectedId) { - this.deleteSelected() + this.probeFrom = null + this.map.probe = null + $('sightline').classList.add('hidden') + this.render() } // F frames the map, but not while flying: it sits beside W A S D. if (e.key === 'f' && this.view === 'map') this.map.fit() } - /** - * A new feature, with every parameter at its kind's default and a seed of its own. - * This is the one moment a random number enters the world (world-generation.md §5.5): - * the seed is saved with the feature, so it generates the same forever after. - */ - private createFeature(kind: FeatureKind, geometry: Feature['geometry']): void { - const name = kind === 'mountains' ? 'New range' : `New ${kind}` - const place = withKind({ - id: makeId(name, this.store.takenIds), - name, geometry, blurb: '', notes: '', public: true, seed: newSeed(), - }, kind) - this.store.add(place) - this.selectedId = place.id - this.setTool('select') - // renderEditor leaves a focused field alone, so set it explicitly here — - // creating a place while the name box has focus would otherwise show the - // previous place's name. - const field = $<HTMLInputElement>('f-name') - field.value = place.name - field.select() - } - // ---------------------------------------------------------------------- view private wireView(): void { @@ -726,88 +585,21 @@ export class App { } } - // -------------------------------------------------------------------- editor - - private buildKindSelect(): void { - const sel = $<HTMLSelectElement>('f-kind') - for (const k of FEATURE_KINDS) { - const o = document.createElement('option') - o.value = k - o.textContent = k - sel.append(o) - } - } + // ------------------------------------------------------------------ readouts - private wireEditor(): void { - const patch = (p: FeaturePatch) => { - if (this.selectedId) this.store.update(this.selectedId, p) - } - $<HTMLInputElement>('f-name').oninput = (e) => - this.rename((e.target as HTMLInputElement).value) - $<HTMLInputElement>('f-id').oninput = (e) => this.setId((e.target as HTMLInputElement).value) - /** - * A kind is a set of parameters, not a label, so changing it swaps the parameters - * for the new kind's defaults. The name, notes, shape and seed carry over. - */ - $<HTMLSelectElement>('f-kind').onchange = (e) => { - const f = this.selectedId ? this.store.get(this.selectedId) : undefined - if (!f) return - const kind = (e.target as HTMLSelectElement).value as FeatureKind - this.store.replace(f.id, withKind(f, kind)) - } - $<HTMLTextAreaElement>('f-blurb').oninput = (e) => - patch({ blurb: (e.target as HTMLTextAreaElement).value }) - $<HTMLTextAreaElement>('f-notes').oninput = (e) => - patch({ notes: (e.target as HTMLTextAreaElement).value }) - $<HTMLInputElement>('f-public').onchange = (e) => - patch({ public: (e.target as HTMLInputElement).checked }) - $('f-delete').onclick = () => this.deleteSelected() - this.wireMountains() - this.wireWater() - this.wireDesert() - this.wireSettlement() - this.wireForest() - this.wireWeather() - } - - /** The wind over a lake or sea, and what sea it raises there. */ - private wireWater(): void { - const body = () => { - const f = this.selectedId ? this.store.get(this.selectedId) : undefined - return f?.kind === 'lake' || f?.kind === 'sea' ? f : null - } - const dir = $<HTMLInputElement>('f-wind-dir') - dir.onchange = () => { - const f = body() - const v = Number(dir.value) - if (f && Number.isFinite(v)) this.store.update(f.id, { wind: { ...f.wind, directionDeg: ((v % 360) + 360) % 360 } }) - } - const speed = $<HTMLInputElement>('f-wind-speed') - speed.oninput = () => { $('f-wind-speed-value').textContent = `— ${speed.value} m/s` } - speed.onchange = () => { - const f = body() - if (f) this.store.update(f.id, { wind: { ...f.wind, speedMS: Number(speed.value) } }) - } - // The sea state is read off the compiled world, so it says what was actually generated. - terrain().onWorld(() => this.renderWater(this.selectedId ? this.store.get(this.selectedId) : undefined)) - } - - private renderWater(f: Feature | undefined): void { - const box = $('f-water') - box.hidden = f?.kind !== 'lake' && f?.kind !== 'sea' - if (f?.kind !== 'lake' && f?.kind !== 'sea') return - const dir = $<HTMLInputElement>('f-wind-dir') - if (document.activeElement !== dir) dir.value = String(f.wind.directionDeg) - const speed = $<HTMLInputElement>('f-wind-speed') - if (document.activeElement !== speed) speed.value = String(f.wind.speedMS) - $('f-wind-speed-value').textContent = `— ${f.wind.speedMS} m/s` + /** + * What the sea is doing over a lake or sea *now*, by the clock: the wind it usually + * has, or whatever the weather plan has instead. Read off the compiled world, so it + * says what was actually generated. + */ + private seaStateText(f: Feature): string { + if (f.kind !== 'lake' && f.kind !== 'sea') return '' const b = terrain().compiled?.basins.find((x) => x.id === f.id) const world = terrain().world - if (!b || !world) { $('f-sea-state').textContent = ''; return } - // Now, by the clock: the wind this body usually has, or whatever the plan has instead. + if (!b || !world) return '' const st = seaStateOf(world, b, this.worldHours()) const weather = st.kind === 'calm' || st.kind === 'breeze' ? '' : ` A ${st.kind} is blowing (${st.wind.speedMS.toFixed(0)} m/s).` - $('f-sea-state').textContent = (st.sea.heightM < 0.01 + return (st.sea.heightM < 0.01 ? 'Flat calm now.' : `Now: waves ${st.sea.heightM.toFixed(1)} m high, ${st.sea.lengthM.toFixed(0)} m apart, ` + `every ${st.sea.periodS.toFixed(1)} s.`) + weather + @@ -816,148 +608,23 @@ export class App { ? ' The wind runs round the ring, so the sea is fully developed.' : '') } - /** What Tower planted in a forest (world-generation.md §4.6). */ - private wireForest(): void { - const selected = (): Extract<Feature, { kind: 'forest' }> | null => { - const f = this.selectedId ? this.store.get(this.selectedId) : undefined - return f?.kind === 'forest' ? f : null - } - const fields: [string, (v: number) => FeaturePatch][] = [ - ['f-density', (v) => ({ densityPerHa: v })], - ['f-conifer', (v) => ({ conifer: v })], - ['f-meadows', (v) => ({ meadows: v })], - ['f-treeline', (v) => ({ treelineM: v })], - ] - for (const [id, patch] of fields) { - const input = $<HTMLInputElement>(id) - input.onchange = () => { - const f = selected() - const v = Number(input.value) - if (f && Number.isFinite(v)) this.store.update(f.id, patch(v)) - } - } - const giants = $<HTMLInputElement>('f-giants') - giants.onchange = () => { - const f = selected() - if (f) this.store.update(f.id, { giants: giants.checked }) - } - terrain().onWorld(() => this.renderForest(this.selectedId ? this.store.get(this.selectedId) : undefined)) - } - - private renderForest(f: Feature | undefined): void { - $('f-forest').hidden = f?.kind !== 'forest' - if (f?.kind !== 'forest') return - const set = (id: string, v: number) => { - const input = $<HTMLInputElement>(id) - if (document.activeElement !== input) input.value = String(v) - } - set('f-density', f.densityPerHa) - set('f-conifer', f.conifer) - set('f-meadows', f.meadows) - set('f-treeline', f.treelineM) - const giants = $<HTMLInputElement>('f-giants') - if (document.activeElement !== giants) giants.checked = f.giants + /** What Tower planted in a forest, over how much ground (world-generation.md §4.6). */ + private forestText(f: Feature): string { + if (f.kind !== 'forest') return '' const c = terrain().compiled?.forests.find((x) => x.id === f.id) - if (!c) { $('f-forest-state').textContent = ''; return } - // Its area, and what that many stems per hectare comes to across all of it. + if (!c) return '' const area = areaOfGeometry(f.geometry) const trees = (area / 10_000) * c.densityPerHa * (1 - f.meadows) const spacing = Math.sqrt(10_000 / c.densityPerHa) - $('f-forest-state').textContent = - `${fmtCount(trees)} trees over ${(area / 1e6).toFixed(0)} km², about ${spacing.toFixed(1)} m apart, ` + return `${fmtCount(trees)} trees over ${(area / 1e6).toFixed(0)} km², about ${spacing.toFixed(1)} m apart, ` + `canopy ${c.canopyM.toFixed(0)} m.` } - /** Where a settlement stands, how big it is, and what it is called for its size. */ - private wireSettlement(): void { - const selected = (): Extract<Feature, { kind: 'settlement' }> | null => { - const f = this.selectedId ? this.store.get(this.selectedId) : undefined - return f?.kind === 'settlement' ? f : null - } - const size = $<HTMLSelectElement>('f-size') - size.onchange = () => { - const f = selected() - if (!f) return - const next = size.value as typeof f.size - // A size carries its usual extent, unless this one was given its own. - const usual = SETTLEMENT_RADIUS_M[f.size] - this.store.update(f.id, { - size: next, - radiusM: Math.abs(f.radiusM - usual) < 1 ? SETTLEMENT_RADIUS_M[next] : f.radiusM, - }) - } - const extent = $<HTMLInputElement>('f-extent') - extent.onchange = () => { - const f = selected() - const km = Number(extent.value) - if (f && Number.isFinite(km) && km > 0) this.store.update(f.id, { radiusM: (km * 1000) / 2 }) - } - } - - private renderSettlement(f: Feature | undefined): void { - $('f-settlement').hidden = f?.kind !== 'settlement' - if (f?.kind !== 'settlement') return - const size = $<HTMLSelectElement>('f-size') - if (document.activeElement !== size) size.value = f.size - const extent = $<HTMLInputElement>('f-extent') - if (document.activeElement !== extent) extent.value = String(((2 * f.radiusM) / 1000).toFixed(1)) - } - - /** - * A desert's landforms (world-generation.md §4.5). Every one of these regenerates the - * desert — a second or so under the status dialog — so they commit on change rather - * than while a number is being typed. - */ - private desertFields(): { id: string; get: (f: DesertFeature) => number; set: (f: DesertFeature, v: number) => FeaturePatch }[] { - return [ - { id: 'f-dune-height', get: (f) => f.dunes.heightM, set: (f, v) => ({ dunes: { ...f.dunes, heightM: v } }) }, - { id: 'f-dune-spacing', get: (f) => f.dunes.spacingM, set: (f, v) => ({ dunes: { ...f.dunes, spacingM: v } }) }, - { id: 'f-dune-dir', get: (f) => f.dunes.directionDeg, set: (f, v) => ({ dunes: { ...f.dunes, directionDeg: v } }) }, - { id: 'f-dune-cover', get: (f) => f.dunes.coverage, set: (f, v) => ({ dunes: { ...f.dunes, coverage: v } }) }, - { id: 'f-hill-height', get: (f) => f.hills.heightM, set: (f, v) => ({ hills: { ...f.hills, heightM: v } }) }, - { id: 'f-hill-cover', get: (f) => f.hills.coverage, set: (f, v) => ({ hills: { ...f.hills, coverage: v } }) }, - { id: 'f-plat-height', get: (f) => f.plateaus.heightM, set: (f, v) => ({ plateaus: { ...f.plateaus, heightM: v } }) }, - { id: 'f-plat-cover', get: (f) => f.plateaus.coverage, set: (f, v) => ({ plateaus: { ...f.plateaus, coverage: v } }) }, - { id: 'f-plat-terraces', get: (f) => f.plateaus.terraces, set: (f, v) => ({ plateaus: { ...f.plateaus, terraces: v } }) }, - { id: 'f-washes', get: (f) => f.pastWater.washes, set: (f, v) => ({ pastWater: { ...f.pastWater, washes: v } }) }, - { id: 'f-pans', get: (f) => f.pastWater.saltPans, set: (f, v) => ({ pastWater: { ...f.pastWater, saltPans: v } }) }, - ] - } - - private selectedDesert(): DesertFeature | null { - const f = this.selectedId ? this.store.get(this.selectedId) : undefined - return f?.kind === 'desert' ? f : null - } - - private wireDesert(): void { - for (const field of this.desertFields()) { - const input = $<HTMLInputElement>(field.id) - input.onchange = () => { - const f = this.selectedDesert() - const v = Number(input.value) - if (f && Number.isFinite(v)) this.store.update(f.id, field.set(f, v)) - } - } - const shores = $<HTMLInputElement>('f-shorelines') - shores.onchange = () => { - const f = this.selectedDesert() - if (f) this.store.update(f.id, { pastWater: { ...f.pastWater, shorelines: shores.checked } }) - } - // What was actually made is read off the compiled world, as the sea state is. - terrain().onWorld(() => this.renderDesert(this.selectedId ? this.store.get(this.selectedId) : undefined)) - } - - private renderDesert(f: Feature | undefined): void { - $('f-desert').hidden = f?.kind !== 'desert' - if (f?.kind !== 'desert') return - for (const field of this.desertFields()) { - const input = $<HTMLInputElement>(field.id) - if (document.activeElement !== input) input.value = String(field.get(f)) - } - const shores = $<HTMLInputElement>('f-shorelines') - if (document.activeElement !== shores) shores.checked = f.pastWater.shorelines + /** What a desert's landforms actually came to (world-generation.md §4.5). */ + private desertText(f: Feature): string { + if (f.kind !== 'desert') return '' const d = terrain().compiled?.deserts.find((x) => x.id === f.id) - if (!d) { $('f-desert-state').textContent = ''; return } + if (!d) return '' let rock = 0, sand = 0, cut = 0, inside = 0 for (let k = 0; k < d.inside.length; k += 7) { if (d.inside[k]! < 0.9) continue @@ -967,11 +634,19 @@ export class App { if (d.cut[k]! > 3) cut++ } const pct = (n: number) => `${Math.round((100 * n) / Math.max(1, inside))}%` - $('f-desert-state').textContent = - `Made: ${pct(rock)} standing rock, ${pct(sand)} dune field, ${pct(cut)} cut by washes, ` + return `${pct(rock)} standing rock, ${pct(sand)} dune field, ${pct(cut)} cut by washes, ` + `${d.pans.length} salt pan${d.pans.length === 1 ? '' : 's'}.` } + /** A range's named summits and their heights, highest first. */ + private summitsText(f: Feature): string { + if (f.kind !== 'mountains' || !f.peaks.length) return '' + return 'Summits: ' + [...f.peaks] + .sort((a, b) => b.elevationM - a.elevationM) + .map((p) => `${p.name || 'unnamed'} ${Math.round(p.elevationM).toLocaleString('en')} m`) + .join(' · ') + } + // --------------------------------------------------------------- weather /** @@ -990,82 +665,23 @@ export class App { } } - /** The event being revised in the form, or null when the form schedules a new one. */ - private revising: { event: string; occurrence: Occurrence } | null = null /** The quarter-hour the board was last drawn for, so a running clock redraws it rarely. */ private weatherDrawnAt = NaN /** - * The weather board: Tower's published plan (world-generation.md §4.9). Scheduling an - * event publishes it now; revising one adds a revision now, and the original stays on - * the board struck through, because people saw it and planned around it. + * The weather board: Tower's published plan (world-generation.md §4.9), as anyone in + * Tower would see it — what has been announced by now, and every revision made so far, + * with the original struck through, because people saw it and planned around it. */ - private wireWeather(): void { - const target = $<HTMLSelectElement>('w-target') - const kind = $<HTMLSelectElement>('w-kind') - const speed = $<HTMLInputElement>('w-speed') - /** A kind suggests a wind; the author can still type any speed. */ - const TYPICAL: Record<WeatherKind, number> = { calm: 1, breeze: 8, gale: 20, storm: 28 } - kind.onchange = () => { speed.value = String(TYPICAL[kind.value as WeatherKind]) } - - const cycle = $<HTMLInputElement>('w-cycle') - cycle.onchange = () => { - const v = Math.max(0, Math.floor(Number(cycle.value) || 0)) - this.store.setWeather({ ...this.store.weather, cycleDays: v }) - } - - $('w-cancel').onclick = () => { this.revising = null; this.renderWeather(true) } - $('w-submit').onclick = () => { - const now = this.worldHours() - const read = (id: string, lo: number, hi: number, fallback: number): number => { - const v = Number($<HTMLInputElement>(id).value) - return Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : fallback - } - const wind = { - directionDeg: ((read('w-dir', -1e6, 1e6, 90) % 360) + 360) % 360, - speedMS: read('w-speed', 0, 60, 10), - } - const k = WEATHER_KINDS.includes(kind.value as WeatherKind) ? (kind.value as WeatherKind) : 'breeze' - const plan = structuredClone(this.store.weather) - if (this.revising) { - const e = plan.events.find((x) => x.id === this.revising!.event) - const o = this.revising.occurrence - if (e) { - // A revision counts from when it is made, within the occurrence it revises. - const at = Math.min(o.endH - 0.01, Math.max(now, o.publishedH)) - e.revisions.push({ - at: timeOf(at), kind: k, wind, - reason: $<HTMLInputElement>('w-reason').value.trim() || 'Tower changed its mind.', - }) - } - this.revising = null - } else { - if (!target.value) return - const start = read('w-day', 0, 1e6, 0) * 24 + read('w-hour', 0, 23.99, 12) - const ids = new Set(plan.events.map((x) => x.id)) - let id = `${target.value}-${k}` - for (let n = 2; ids.has(id); n++) id = `${target.value}-${k}-${n}` - const event: WeatherEvent = { - id, target: target.value, kind: k, start: timeOf(start), - durationH: read('w-duration', 0.5, 24 * 14, 12), wind, - publishedAt: timeOf(Math.min(now, start)), revisions: [], - } - plan.events.push(event) - } - this.store.setWeather(plan) - } - this.renderWeather(true) - } - private renderWeather(force = false): void { const now = this.worldHours() const bucket = Math.floor(now * 4) if (!force && bucket === this.weatherDrawnAt) return this.weatherDrawnAt = bucket // The selected lake or sea's readout is a function of the clock too. - if (!force) this.renderWater(this.selectedId ? this.store.get(this.selectedId) : undefined) - const plan = this.store.weather - const bodies = this.store.features.filter((f) => f.kind === 'lake' || f.kind === 'sea') + if (!force) this.renderDetail() + const plan = this.world.weather + const bodies = this.world.features.filter((f) => f.kind === 'lake' || f.kind === 'sea') const nameOf = (id: string) => bodies.find((f) => f.id === id)?.name ?? id const when = (h: number): string => { const t = timeOf(h) @@ -1076,9 +692,9 @@ export class App { const board = $('weather-board') board.replaceChildren() - // What is on, and the next fortnight. Readers see only what has been published. + // What is on, and the next fortnight, as far as it has been published. const shown = occurrences(plan, null, now, now + 24 * 14) - .filter((o) => o.endH > now && (this.writer || o.publishedH <= now)) + .filter((o) => o.endH > now && o.publishedH <= now) if (!shown.length) { const li = document.createElement('li') li.className = 'dim' @@ -1096,281 +712,45 @@ export class App { if (made.length) head.append(Object.assign(document.createElement('s'), { textContent: text })) else head.textContent = text li.append(head) - if (o.publishedH > now) { - li.append(Object.assign(document.createElement('div'), { - className: 'rev', textContent: `Not yet published: announced ${when(o.publishedH)}.`, - })) - } - for (const r of o.revisions) { - const future = hoursOf(r.at) > now - if (future && !this.writer) continue + for (const r of made) { li.append(Object.assign(document.createElement('div'), { className: 'rev', - textContent: `${future ? 'Will revise' : 'Revised'} ${when(hoursOf(r.at))}: ${r.kind}, ${r.wind.speedMS} m/s. ${r.reason}`, + textContent: `Revised ${when(hoursOf(r.at))}: ${r.kind}, ${r.wind.speedMS} m/s. ${r.reason}`, })) } - if (this.writer) { - const acts = document.createElement('div') - acts.className = 'acts' - const revise = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Revise' }) - revise.onclick = () => { - this.revising = { event: o.event.id, occurrence: o } - const last = o.revisions.at(-1) - $<HTMLSelectElement>('w-kind').value = last?.kind ?? o.event.kind - $<HTMLInputElement>('w-speed').value = String(last?.wind.speedMS ?? o.event.wind.speedMS) - $<HTMLInputElement>('w-dir').value = String(last?.wind.directionDeg ?? o.event.wind.directionDeg) - $<HTMLInputElement>('w-reason').value = '' - this.renderWeather(true) - } - const del = Object.assign(document.createElement('button'), { - type: 'button', textContent: 'Delete', className: 'danger', - }) - del.onclick = () => this.store.setWeather({ - ...plan, events: plan.events.filter((e) => e.id !== o.event.id), - }) - acts.append(revise, del) - li.append(acts) - } board.append(li) } - - $('weather-edit').hidden = !this.writer - if (!this.writer) return - const target = $<HTMLSelectElement>('w-target') - const chosen = target.value - target.replaceChildren(...bodies.map((f) => Object.assign(document.createElement('option'), { - value: f.id, textContent: f.name, - }))) - if (bodies.some((f) => f.id === chosen)) target.value = chosen - const r = this.revising - $('w-title').textContent = r - ? `Revise the ${r.occurrence.event.kind} over ${nameOf(r.occurrence.event.target)}, from now` - : 'Schedule weather — published as soon as it is added' - for (const id of ['w-target', 'w-day', 'w-hour', 'w-duration']) $<HTMLInputElement>(id).disabled = !!r - $('w-reason-row').hidden = !r - $('w-cancel').hidden = !r - $('w-submit').textContent = r ? 'Publish the revision' : 'Publish' - const day = $<HTMLInputElement>('w-day') - if (document.activeElement !== day && !day.value) { - day.value = String(timeOf(now + 24).day) - $<HTMLInputElement>('w-hour').value = '12' - } - const cycle = $<HTMLInputElement>('w-cycle') - if (document.activeElement !== cycle) cycle.value = String(plan.cycleDays) - } - - /** - * A range's summits and parameters. Sliders commit on release rather than while being - * dragged: every change regenerates the range, which takes a few seconds for the - * Girdle, and the service would only throw the in-between values away (service.ts). - */ - private selectedRange(): Extract<Feature, { kind: 'mountains' }> | null { - const f = this.selectedId ? this.store.get(this.selectedId) : undefined - return f?.kind === 'mountains' ? f : null - } - - private wireMountains(): void { - const range = () => this.selectedRange() - const num = (id: string, key: 'heightM' | 'snowlineM', min: number) => { - const el = $<HTMLInputElement>(id) - el.onchange = () => { - const f = range() - const v = Number(el.value) - if (f && Number.isFinite(v)) this.store.update(f.id, { [key]: Math.max(min, v) }) - } - } - num('f-height', 'heightM', 0) - num('f-snow', 'snowlineM', -1000) - for (const [id, key] of [['f-gen', 'generatedPeaks'], ['f-rugged', 'ruggedness'], ['f-erosion', 'erosion']] as const) { - const el = $<HTMLInputElement>(id) - el.oninput = () => { $(`${id}-value`).textContent = `— ${Number(el.value).toFixed(2)}` } - el.onchange = () => { - const f = range() - if (f) this.store.update(f.id, { [key]: Number(el.value) }) - } - } - $('f-add-peak').onclick = () => { - const f = range() - this.setAddingPeak(this.addingPeakTo || !f ? null : f.id) - } - } - - private renderMountains(f: Feature | undefined): void { - const box = $('f-mountains') - box.hidden = f?.kind !== 'mountains' - if (f?.kind !== 'mountains') { - this.setAddingPeak(null) - return - } - const setIfIdle = (id: string, v: string) => { - const el = $<HTMLInputElement>(id) - if (document.activeElement !== el) el.value = v - } - setIfIdle('f-height', String(f.heightM)) - setIfIdle('f-snow', String(f.snowlineM)) - for (const [id, v] of [['f-gen', f.generatedPeaks], ['f-rugged', f.ruggedness], ['f-erosion', f.erosion]] as const) { - setIfIdle(id, String(v)) - $(`${id}-value`).textContent = `— ${v.toFixed(2)}` - } - - // The summit rows are rebuilt only when their count changes, so typing a name is - // never interrupted by a re-render replacing the field under the cursor. - const list = $('f-peaks') - if (list.childElementCount !== f.peaks.length || list.dataset['range'] !== f.id) { - list.replaceChildren() - list.dataset['range'] = f.id - f.peaks.forEach((_, i) => { - const li = document.createElement('li') - const name = document.createElement('input') - name.type = 'text' - name.placeholder = 'Unnamed summit' - const elev = document.createElement('input') - elev.type = 'number' - elev.step = '10' - elev.title = 'Elevation, m' - const del = document.createElement('button') - del.type = 'button' - del.textContent = '×' - del.title = 'Remove this summit' - const edit = (patch: Partial<{ name: string; elevationM: number }>) => { - const cur = this.selectedRange() - if (!cur?.peaks[i]) return - this.store.update(cur.id, { peaks: cur.peaks.map((p, k) => (k === i ? { ...p, ...patch } : p)) }) - } - name.oninput = () => edit({ name: name.value }) - elev.onchange = () => { if (Number.isFinite(Number(elev.value))) edit({ elevationM: Number(elev.value) }) } - del.onclick = () => { - const cur = this.selectedRange() - if (cur) this.store.update(cur.id, { peaks: cur.peaks.filter((_, k) => k !== i) }) - } - li.append(name, elev, del) - list.append(li) - }) - } - ;[...list.children].forEach((li, i) => { - const [name, elev] = li.querySelectorAll('input') - if (name && document.activeElement !== name) name.value = f.peaks[i]!.name - if (elev && document.activeElement !== elev) elev.value = String(f.peaks[i]!.elevationM) - }) - } - - /** - * Rename, letting the id follow along. Ids are permalink targets and must be - * stable once published, but during world-building names churn constantly and a - * file full of "new-city-7" is useless to read or hand-edit. So the id tracks the - * name only while it still matches it; editing the id field pins it for good. - */ - private rename(name: string): void { - const place = this.selectedId ? this.store.get(this.selectedId) : undefined - if (!place) return - const idWasAuto = isAutoId(place.id, place.name) - this.store.update(place.id, { name }) - if (!idWasAuto) return - this.reid(place.id, name) - } - - /** - * Move a place to a new id. `selectedId` is updated *before* the store emits, - * because the store's re-render looks the selection up by id and would otherwise - * find nothing and blank the editor mid-rename. - */ - private reid(from: string, source: string): void { - const taken = new Set([...this.store.takenIds].filter((i) => i !== from)) - const next = makeId(source, taken) - if (next === from) return - this.selectedId = next - if (!this.store.setId(from, next)) this.selectedId = from - } - - private setId(raw: string): void { - if (this.selectedId) this.reid(this.selectedId, raw) - } - - private deleteSelected(): void { - if (!this.selectedId) return - const place = this.store.get(this.selectedId) - if (place && !confirm(`Delete "${place.name}"?`)) return - this.store.remove(this.selectedId) - this.selectedId = null - this.render() - } - - // ---------------------------------------------------------------------- data - - private wireData(): void { - $('export-backup').onclick = () => { - this.store.flush() - download(backupFilename(), this.store.fullJSON()) - } - $('export-static').onclick = () => { - this.store.flush() - download('world.json', this.store.publicJSON()) - } - $('reseed').onclick = async () => { - const n = this.store.features.length - if (n && !confirm(`Replace ${n} local feature(s) with the published world?`)) return - const loaded = await this.store.seedFrom(`${import.meta.env.BASE_URL}world.json`) - this.selectedId = null - this.store.flush() - this.render() - if (!loaded) alert('No published world.json was found to load.') - } - - const file = $<HTMLInputElement>('import-file') - $('import').onclick = () => file.click() - file.onchange = async () => { - const f = file.files?.[0] - if (!f) return - try { - const world = parseWorld(JSON.parse(await f.text())) - const n = this.store.features.length - if (n && !confirm(`Replace ${n} feature(s) with ${world.features.length} from ${f.name}?`)) return - this.store.replaceAll(world) - this.selectedId = null - } catch (err) { - alert(`Could not read that file.\n\n${err instanceof Error ? err.message : String(err)}`) - } finally { - file.value = '' - } - } } // -------------------------------------------------------------------- render private render(): void { - for (const b of document.querySelectorAll<HTMLButtonElement>('#tools button')) { - b.classList.toggle('active', b.dataset['tool'] === this.tool) - } - - this.map.places = this.store.features + this.map.places = this.world.features this.map.selectedId = this.selectedId - this.map.dragEnabled = this.tool === 'select' if (this.view === 'exterior') { this.renderSpinReadout() } if (this.view === 'interior') { - this.interior?.setPlaces(this.store.features.filter((p) => p.geometry.type !== 'band')) + this.interior?.setPlaces(this.world.features.filter((p) => p.geometry.type !== 'band')) this.renderStanding() } this.map.draw() - const total = this.store.features.length - const pub = this.store.publicCount() - $('counts').textContent = `— ${total} feature${total === 1 ? '' : 's'}, ${pub} public` + const total = this.world.features.length + $('counts').textContent = `— ${total} feature${total === 1 ? '' : 's'}` this.renderList() this.renderWeather(true) - this.renderEditor() - this.renderSave() + this.renderDetail() this.writeHash() } private renderList(): void { const ul = $('list') ul.replaceChildren() - for (const p of [...this.store.features].sort((a, b) => a.name.localeCompare(b.name))) { + for (const p of [...this.world.features].sort((a, b) => a.name.localeCompare(b.name))) { const li = document.createElement('li') - li.className = (p.id === this.selectedId ? 'sel ' : '') + (p.public ? '' : 'private') + if (p.id === this.selectedId) li.className = 'sel' li.onclick = () => { this.selectedId = p.id this.focusOn(p.id) @@ -1391,62 +771,27 @@ export class App { nm.textContent = p.name const kd = document.createElement('span') kd.className = 'kd' - kd.textContent = p.public ? p.kind : `${p.kind} · private` + kd.textContent = p.kind li.append(sw, nm, kd) ul.append(li) } } - private renderEditor(): void { - const editor = $<HTMLDetailsElement>('editor') - const detail = $<HTMLDetailsElement>('detail') - const p = this.selectedId ? this.store.get(this.selectedId) : undefined - - // Readers get a read-only card. Without this branch renderEditor would happily - // un-hide the edit panel — including the private notes field — for everyone. - if (!this.writer) { - editor.hidden = true - detail.hidden = !p - if (p) { - $('d-name').textContent = p.name - $('d-blurb').textContent = p.blurb || `A ${p.kind}.` - $('d-where').textContent = this.whereText(p) - } - return - } - - detail.hidden = true - if (!p) { - editor.hidden = true - return - } - editor.hidden = false - - const name = $<HTMLInputElement>('f-name') - if (document.activeElement !== name) name.value = p.name - const id = $<HTMLInputElement>('f-id') - if (document.activeElement !== id) id.value = p.id - // Only the kinds this shape can be: a river cannot become a lake without a shore. - const kinds = $<HTMLSelectElement>('f-kind') - for (const o of kinds.options) { - o.hidden = !geometryTypesFor(o.value as FeatureKind).includes(p.geometry.type) - } - kinds.value = p.kind - const blurb = $<HTMLTextAreaElement>('f-blurb') - if (document.activeElement !== blurb) blurb.value = p.blurb - const notes = $<HTMLTextAreaElement>('f-notes') - if (document.activeElement !== notes) notes.value = p.notes - $<HTMLInputElement>('f-public').checked = p.public - this.renderMountains(p) - this.renderWater(p) - this.renderDesert(p) - this.renderSettlement(p) - this.renderForest(p) - - $('f-where').textContent = this.whereText(p) + /** The selected place: its name, its blurb, where it is, and what it is like now. */ + private renderDetail(): void { + const p = this.selectedId ? this.get(this.selectedId) : undefined + $('detail').hidden = !p + if (!p) return + $('d-name').textContent = p.name + $('d-blurb').textContent = p.blurb || `A ${p.kind}.` + $('d-where').textContent = this.whereText(p) + const state = [this.summitsText(p), this.seaStateText(p), this.forestText(p), this.desertText(p)] + .filter(Boolean).join(' ') + $('d-state').textContent = state + $('d-state').hidden = !state } - /** Human-readable location, shared by the editor and the reader's detail card. */ + /** Human-readable location, for the detail card. */ private whereText(p: Feature): string { const g = p.geometry if (g.type === 'point') { @@ -1550,7 +895,7 @@ export class App { const span = Math.min(100_000, deepward ? LENGTH_M - here.z : here.z) const along = sightline(here, { theta: here.theta, z: here.z + (deepward ? span : -span) }) const rep = v.report() - const name = this.store.features.find((p) => + const name = this.world.features.find((p) => p.geometry.type === 'point' && Math.abs(p.geometry.z - here.z) < 2000 && Math.abs(p.geometry.theta - here.theta) < 0.04)?.name @@ -1562,17 +907,4 @@ export class App { `${(`${km(span)} on`).padEnd(10)} ${pct(along.transmission)} clear — the ground is the vague part`, ].join('\n') } - - private renderSave(): void { - const el = $('save') - el.classList.toggle('error', this.store.saveState === 'error') - if (this.store.storageError) { - el.textContent = `Not saving locally: ${this.store.storageError}. Export a backup.` - } else if (this.store.saveState === 'saving') { - el.textContent = 'Saving…' - } else { - const t = this.store.lastSaved - el.textContent = t ? `Saved locally at ${t.toLocaleTimeString()}` : 'Saved locally' - } - } } diff --git a/src/views/map.ts b/src/views/map.ts index 1c69832..51f9f3c 100644 --- a/src/views/map.ts +++ b/src/views/map.ts @@ -21,9 +21,6 @@ import { KIND_COLOR } from './palette.js' export interface MapCallbacks { onPick: (p: SurfacePoint, hit: Feature | null, shiftKey: boolean) => void - onDragPlace: (id: string, p: SurfacePoint) => void - /** A summit of a range dragged to a new place. */ - onDragPeak: (id: string, index: number, p: SurfacePoint) => void onHover: (p: SurfacePoint | null) => void } @@ -39,19 +36,11 @@ export class TowerMap { places: readonly Feature[] = [] selectedId: string | null = null probe: { from: SurfacePoint; to: SurfacePoint | null } | null = null - draftPolygon: [number, number][] = [] - /** - * Whether pressing on a marker grabs it. False while a creation tool is active, - * so that clicking on top of an existing place creates a new one rather than - * silently dragging the old one. - */ - dragEnabled = true /** The generated terrain, drawn under everything. Null until the first world is drawn. */ private terrain: CanvasImageSource | null = null showTerrain = true - private dragging: { id: string; peak: number | null } | null = null private panning: { x: number; y: number; ox: number; oy: number } | null = null private moved = false @@ -145,28 +134,13 @@ export class TowerMap { this.canvas.addEventListener('pointerdown', (e) => { const { px, py } = this.eventPoint(e) this.moved = false - const hit = this.hitTest(px, py) - const peak = this.dragEnabled && !e.shiftKey ? this.hitPeak(px, py) : null - if (peak) { - this.dragging = { id: peak.feature.id, peak: peak.index } - this.canvas.setPointerCapture(e.pointerId) - } else if (this.dragEnabled && hit && hit.geometry.type === 'point' && !e.shiftKey) { - this.dragging = { id: hit.id, peak: null } - this.canvas.setPointerCapture(e.pointerId) - } else { - this.panning = { x: px, y: py, ox: this.offsetX, oy: this.offsetY } - this.canvas.setPointerCapture(e.pointerId) - } + this.panning = { x: px, y: py, ox: this.offsetX, oy: this.offsetY } + this.canvas.setPointerCapture(e.pointerId) }) this.canvas.addEventListener('pointermove', (e) => { const { px, py } = this.eventPoint(e) - if (this.dragging) { - this.moved = true - const at = this.surfaceAt(px, py) - if (this.dragging.peak === null) this.cb.onDragPlace(this.dragging.id, at) - else this.cb.onDragPeak(this.dragging.id, this.dragging.peak, at) - } else if (this.panning) { + if (this.panning) { if (Math.hypot(px - this.panning.x, py - this.panning.y) > 3) this.moved = true this.offsetX = this.panning.ox + (px - this.panning.x) this.offsetY = this.panning.oy + (py - this.panning.y) @@ -179,18 +153,15 @@ export class TowerMap { const end = (e: PointerEvent) => { const { px, py } = this.eventPoint(e) - const wasDragging = this.dragging - this.dragging = null this.panning = null if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId) - // A summit clicked without dragging selects its range, like clicking the range. - if (!this.moved && (!wasDragging || wasDragging.peak !== null)) { + // A summit clicked selects its range, like clicking the range. + if (!this.moved) { this.cb.onPick(this.surfaceAt(px, py), this.hitPeak(px, py)?.feature ?? this.hitTest(px, py), e.shiftKey) } } this.canvas.addEventListener('pointerup', end) this.canvas.addEventListener('pointercancel', () => { - this.dragging = null this.panning = null }) this.canvas.addEventListener('pointerleave', () => this.cb.onHover(null)) @@ -293,7 +264,6 @@ export class TowerMap { this.drawCaps() this.drawPlaces() this.drawProbe() - this.drawDraft() this.drawScaleBar() } @@ -641,26 +611,6 @@ export class TowerMap { this.ctx.lineTo(bx, by) this.ctx.stroke() } - - private drawDraft(): void { - if (this.draftPolygon.length === 0) return - const ctx = this.ctx - ctx.beginPath() - this.tracePath(this.draftPolygon) - ctx.strokeStyle = '#7fd4ff' - ctx.lineWidth = 2 - ctx.setLineDash([5, 4]) - ctx.stroke() - ctx.setLineDash([]) - for (const [theta, z] of this.draftPolygon) { - const m = toMap({ theta, z }) - const [sx, sy] = this.worldToScreen(m.x, m.y) - ctx.beginPath() - ctx.arc(sx, sy, 3.5, 0, Math.PI * 2) - ctx.fillStyle = '#7fd4ff' - ctx.fill() - } - } } function centroid(points: readonly [number, number][]): [number, number] { diff --git a/src/world/schema.ts b/src/world/schema.ts index 6b6ef94..98f9fb4 100644 --- a/src/world/schema.ts +++ b/src/world/schema.ts @@ -13,7 +13,7 @@ * notes — the author's working notes, NEVER shipped * plus `public: false` for a whole entry that is not ready to exist publicly. * - * Every kind has the same `geometry` field, so the map, the list and the editor + * Every kind has the same `geometry` field, so the map, the list and the detail card * handle all of them one way. What differs by kind is the parameters beside it, and * each phase of world-generation.md §14 adds the parameters its generators use — * nothing is declared here before something reads it. @@ -311,10 +311,7 @@ export const emptyWorld = (): World => ({ version: WORLD_VERSION, seed: 1, ground: defaultGround(), features: [], weather: emptyWeather(), }) -/** A fresh seed for a new feature — the one place generation is allowed a random number. */ -export const newSeed = (): number => Math.floor(Math.random() * 0x7fffffff) - -/** Which shapes a kind may take. The first is what its tool draws. */ +/** Which shapes a kind may take, the usual one first. */ export function geometryTypesFor(kind: FeatureKind): readonly GeometryType[] { if (kind === 'river' || kind === 'road' || kind === 'path') return ['polyline'] if (kind === 'lake') return ['polygon'] @@ -335,7 +332,7 @@ type Common = Pick<Feature, 'id' | 'name' | 'geometry' | 'blurb' | 'notes' | 'pu * it was drawn, and its soft edge reaches outward (field.ts). The world's own ringing * belts carry the wide edges v1 gave them. * - * Used when creating a feature and when changing an existing one's kind. + * What a new feature starts from, before its own parameters are written in. */ export function withKind(common: Common, kind: FeatureKind): Feature { const c = { ...common } @@ -380,6 +377,7 @@ export function withKind(common: Common, kind: FeatureKind): Feature { // -------------------------------------------------------------------------- ids +/** An id from a name, unique among `taken`. Also fills in a file's missing ids. */ export function makeId(name: string, taken: ReadonlySet<string>): string { const base = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'place' @@ -387,16 +385,6 @@ export function makeId(name: string, taken: ReadonlySet<string>): string { for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}` } -/** - * True if `id` looks auto-derived from `name` rather than pinned by the author. - * Ids are permalink targets, so they follow the name only while they still match - * it; once edited by hand they stop tracking. See src/ui/app.ts `rename`. - */ -export function isAutoId(id: string, name: string): boolean { - const slug = makeId(name, new Set()) - return id === slug || new RegExp(`^${slug}-\\d+$`).test(id) -} - /** A single representative point for a feature, whatever shape it is. */ /** How far the usual settlement of each size reaches from its marker, metres. */ export const SETTLEMENT_RADIUS_M: Record<Settlement['size'], number> = { diff --git a/src/world/store.ts b/src/world/store.ts deleted file mode 100644 index 788fdeb..0000000 --- a/src/world/store.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * World persistence. See design.md §3.1. - * - * The author's loop is: edit in the browser, autosaved to localStorage constantly; - * export a full backup on whatever schedule they like; export the static public - * content once to seed the repo. There is no backend — GitLab Pages is static. - */ -import { - type Feature, type WeatherPlan, type World, emptyWorld, parseWorld, toPublicWorld, -} from './schema.js' - -/** - * v2, since the world file changed shape (D-062). A v1 draft under the old key is left - * where it is rather than read: it holds places, not features, and would load empty. - */ -const STORAGE_KEY = 'tower.world.v2' - -/** - * Some fields of some kind of feature. A union of each kind's partial, so a patch may - * name a range's `peaks` or a lake's `levelM`, which a partial of the union could not. - */ -export type FeaturePatch = Feature extends infer F ? (F extends Feature ? Partial<Omit<F, 'id'>> : never) : never -const SAVE_DEBOUNCE_MS = 400 - -export type SaveState = 'saved' | 'saving' | 'error' -type Listener = () => void - -export class WorldStore { - private world: World = emptyWorld() - private listeners = new Set<Listener>() - private saveTimer: ReturnType<typeof setTimeout> | null = null - - saveState: SaveState = 'saved' - lastSaved: Date | null = null - /** Set when localStorage is unavailable (private window, blocked site data). */ - storageError: string | null = null - - // ------------------------------------------------------------------- lifecycle - - /** - * Restore the author's local draft, falling back to the committed `world.json`. - * - * An *empty* draft counts as no draft. Opening the app once while the seed was - * empty used to persist an empty world, which then shadowed the real one - * permanently — the published map was right there and simply never loaded. There - * is nothing to protect in a world with no places, and the alternative failure - * (a deliberately emptied world refilling on reload) is visible and one click to - * undo, where this one was silent. - */ - async load(seedUrl: string): Promise<void> { - const stored = this.readStorage() - if (stored && stored.features.length > 0) { - this.world = stored - this.emit() - return - } - await this.seedFrom(seedUrl) - } - - /** Replace the world with the published one. Also the manual recovery path. */ - async seedFrom(seedUrl: string): Promise<number> { - try { - const res = await fetch(seedUrl) - if (res.ok) this.world = parseWorld(await res.json()) - } catch { - // No seed file yet is entirely normal — start empty. - } - this.emit() - return this.world.features.length - } - - private readStorage(): World | null { - try { - const raw = localStorage.getItem(STORAGE_KEY) - return raw ? parseWorld(JSON.parse(raw)) : null - } catch (err) { - this.storageError = err instanceof Error ? err.message : String(err) - return null - } - } - - subscribe(fn: Listener): () => void { - this.listeners.add(fn) - return () => this.listeners.delete(fn) - } - - private emit(): void { - for (const fn of this.listeners) fn() - } - - // ------------------------------------------------------------------ autosaving - - private touch(): void { - this.saveState = 'saving' - this.emit() - if (this.saveTimer) clearTimeout(this.saveTimer) - this.saveTimer = setTimeout(() => this.flush(), SAVE_DEBOUNCE_MS) - } - - /** Write immediately. Also called on pagehide, so nothing is lost on close. */ - flush(): void { - if (this.saveTimer) { - clearTimeout(this.saveTimer) - this.saveTimer = null - } - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this.world)) - this.saveState = 'saved' - this.lastSaved = new Date() - this.storageError = null - } catch (err) { - this.saveState = 'error' - this.storageError = err instanceof Error ? err.message : String(err) - } - this.emit() - } - - // ------------------------------------------------------------------------ CRUD - - /** The whole world, for the terrain service. Read-only by convention. */ - get current(): World { - return this.world - } - - get features(): readonly Feature[] { - return this.world.features - } - - get(id: string): Feature | undefined { - return this.world.features.find((p) => p.id === id) - } - - /** Tower's weather plan. Edits go through `setWeather`, so they are saved. */ - get weather(): WeatherPlan { - return this.world.weather - } - - setWeather(plan: WeatherPlan): void { - this.world.weather = plan - this.touch() - } - - get takenIds(): ReadonlySet<string> { - return new Set(this.world.features.map((p) => p.id)) - } - - add(feature: Feature): Feature { - this.world.features.push(feature) - this.touch() - return feature - } - - update(id: string, patch: FeaturePatch): void { - const feature = this.get(id) - if (!feature) return - Object.assign(feature, patch) - this.touch() - } - - /** Swap a feature for another at the same position — a change of kind. */ - replace(id: string, next: Feature): void { - const i = this.world.features.findIndex((p) => p.id === id) - if (i < 0) return - this.world.features[i] = next - this.touch() - } - - /** - * Change a place's id, keeping order. Ids are permalink targets, so this is - * deliberate and explicit rather than a side effect of `update`. - */ - setId(oldId: string, newId: string): boolean { - if (oldId === newId || this.get(newId)) return false - const feature = this.get(oldId) - if (!feature) return false - feature.id = newId - // A river names what it flows into by id; renaming The Deep must not strand it. - for (const f of this.world.features) { - if (f.kind === 'river' && f.outlet === oldId) f.outlet = newId - } - this.touch() - return true - } - - remove(id: string): void { - const i = this.world.features.findIndex((p) => p.id === id) - if (i >= 0) { - this.world.features.splice(i, 1) - this.touch() - } - } - - replaceAll(world: World): void { - this.world = world - this.touch() - } - - // ------------------------------------------------------------------- transfer - - /** Everything, including private notes and unpublished places. The backup. */ - fullJSON(): string { - return JSON.stringify(this.world, null, 2) - } - - /** Public places only, notes stripped. The file that ships in the repo. */ - publicJSON(): string { - return JSON.stringify(toPublicWorld(this.world), null, 2) - } - - publicCount(): number { - return this.world.features.filter((p) => p.public).length - } -} - -/** Hand the browser a file to save. */ -export function download(filename: string, contents: string): void { - const url = URL.createObjectURL(new Blob([contents], { type: 'application/json' })) - const a = document.createElement('a') - a.href = url - a.download = filename - a.click() - URL.revokeObjectURL(url) -} - -export const backupFilename = (now = new Date()): string => - `tower-backup-${now.toISOString().slice(0, 10)}.json` diff --git a/static/world.json b/static/world.json deleted file mode 100644 index 5fa89ca..0000000 --- a/static/world.json +++ /dev/null @@ -1,1140 +0,0 @@ -{ - "version": 2, - "seed": 1729, - "ground": { - "baseM": 60, - "reliefM": 260, - "notes": "" - }, - "features": [ - { - "id": "sunward-sea", - "name": "The Sunward Sea", - "blurb": "", - "notes": "", - "public": true, - "seed": 1437950893, - "kind": "sea", - "geometry": { - "type": "band", - "zFrom": 0, - "zTo": 42000 - }, - "levelM": 90, - "depthM": 420, - "shoreM": 16000 - }, - { - "id": "deepward-sea", - "name": "The Deepward Sea", - "blurb": "", - "notes": "", - "public": true, - "seed": 1346489887, - "kind": "sea", - "geometry": { - "type": "band", - "zFrom": 496000, - "zTo": 540000 - }, - "levelM": 90, - "depthM": 420, - "shoreM": 16000 - }, - { - "id": "sun-cap", - "name": "The Sun Cap", - "blurb": "", - "notes": "", - "public": true, - "seed": 722399060, - "kind": "landmark", - "geometry": { - "type": "point", - "theta": 0, - "z": 0 - } - }, - { - "id": "far-cap", - "name": "The Far Cap", - "blurb": "", - "notes": "", - "public": true, - "seed": 1439513367, - "kind": "landmark", - "geometry": { - "type": "point", - "theta": 0, - "z": 540000 - } - }, - { - "id": "snowcrown", - "name": "Snowcrown", - "blurb": "", - "notes": "", - "public": true, - "seed": 1497766597, - "kind": "mountains", - "geometry": { - "type": "polygon", - "points": [ - [0.9, 88000], - [0.894867, 92307], - [0.879555, 96541], - [0.854328, 100629], - [0.819615, 104500], - [0.776012, 108089], - [0.724264, 111335], - [0.665257, 114181], - [0.6, 116579], - [0.52961, 118488], - [0.455291, 119876], - [0.378316, 120718], - [0.3, 121000], - [0.221684, 120718], - [0.144709, 119876], - [0.07039, 118488], - [0, 116579], - [-0.065257, 114181], - [-0.124264, 111335], - [-0.176012, 108089], - [-0.219615, 104500], - [-0.254328, 100629], - [-0.279555, 96541], - [-0.294867, 92307], - [-0.3, 88000], - [-0.294867, 83693], - [-0.279555, 79459], - [-0.254328, 75371], - [-0.219615, 71500], - [-0.176012, 67911], - [-0.124264, 64665], - [-0.065257, 61819], - [0, 59421], - [0.07039, 57512], - [0.144709, 56124], - [0.221684, 55282], - [0.3, 55000], - [0.378316, 55282], - [0.455291, 56124], - [0.52961, 57512], - [0.6, 59421], - [0.665257, 61819], - [0.724264, 64665], - [0.776012, 67911], - [0.819615, 71500], - [0.854328, 75371], - [0.879555, 79459], - [0.894867, 83693] - ] - }, - "peaks": [ - { - "name": "Snowcrown", - "theta": 0.3, - "z": 88000, - "elevationM": 4300 - } - ], - "heightM": 4300, - "ruggedness": 0.55, - "snowlineM": 3000 - }, - { - "id": "the-sisters", - "name": "The Sisters", - "blurb": "", - "notes": "", - "public": true, - "seed": 1441553728, - "kind": "mountains", - "geometry": { - "type": "polygon", - "points": [ - [0, 94000], - [-0.004449, 97655], - [-0.017719, 101247], - [-0.039583, 104715], - [-0.069667, 108000], - [-0.107456, 111045], - [-0.152304, 113799], - [-0.203444, 116214], - [-0.26, 118249], - [-0.321005, 119869], - [-0.385414, 121046], - [-0.452126, 121760], - [-0.52, 122000], - [-0.587874, 121760], - [-0.654586, 121046], - [-0.718995, 119869], - [-0.78, 118249], - [-0.836556, 116214], - [-0.887696, 113799], - [-0.932544, 111045], - [-0.970333, 108000], - [-1.000417, 104715], - [-1.022281, 101247], - [-1.035551, 97655], - [-1.04, 94000], - [-1.035551, 90345], - [-1.022281, 86753], - [-1.000417, 83285], - [-0.970333, 80000], - [-0.932544, 76955], - [-0.887696, 74201], - [-0.836556, 71786], - [-0.78, 69751], - [-0.718995, 68131], - [-0.654586, 66954], - [-0.587874, 66240], - [-0.52, 66000], - [-0.452126, 66240], - [-0.385414, 66954], - [-0.321005, 68131], - [-0.26, 69751], - [-0.203444, 71786], - [-0.152304, 74201], - [-0.107456, 76955], - [-0.069667, 80000], - [-0.039583, 83285], - [-0.017719, 86753], - [-0.004449, 90345] - ] - }, - "peaks": [ - { - "name": "The Sisters", - "theta": -0.52, - "z": 94000, - "elevationM": 3650 - } - ], - "heightM": 3650, - "ruggedness": 0.55, - "snowlineM": 3000 - }, - { - "id": "westwall", - "name": "Westwall", - "blurb": "", - "notes": "", - "public": true, - "seed": 946001310, - "kind": "mountains", - "geometry": { - "type": "polygon", - "points": [ - [-0.62, 86000], - [-0.624106, 89394], - [-0.636356, 92729], - [-0.656538, 95950], - [-0.684308, 99000], - [-0.71919, 101828], - [-0.760589, 104385], - [-0.807795, 106627], - [-0.86, 108517], - [-0.916312, 110021], - [-0.975767, 111114], - [-1.037347, 111778], - [-1.1, 112000], - [-1.162653, 111778], - [-1.224233, 111114], - [-1.283688, 110021], - [-1.34, 108517], - [-1.392205, 106627], - [-1.439411, 104385], - [-1.48081, 101828], - [-1.515692, 99000], - [-1.543462, 95950], - [-1.563644, 92729], - [-1.575894, 89394], - [-1.58, 86000], - [-1.575894, 82606], - [-1.563644, 79271], - [-1.543462, 76050], - [-1.515692, 73000], - [-1.48081, 70172], - [-1.439411, 67615], - [-1.392205, 65373], - [-1.34, 63483], - [-1.283688, 61979], - [-1.224233, 60886], - [-1.162653, 60222], - [-1.1, 60000], - [-1.037347, 60222], - [-0.975767, 60886], - [-0.916312, 61979], - [-0.86, 63483], - [-0.807795, 65373], - [-0.760589, 67615], - [-0.71919, 70172], - [-0.684308, 73000], - [-0.656538, 76050], - [-0.636356, 79271], - [-0.624106, 82606] - ] - }, - "peaks": [ - { - "name": "Westwall", - "theta": -1.1, - "z": 86000, - "elevationM": 3400 - } - ], - "heightM": 3400, - "ruggedness": 0.55, - "snowlineM": 3000 - }, - { - "id": "eastwall", - "name": "Eastwall", - "blurb": "", - "notes": "", - "public": true, - "seed": 653882312, - "kind": "mountains", - "geometry": { - "type": "polygon", - "points": [ - [1.48, 92000], - [1.475722, 95524], - [1.462963, 98988], - [1.44194, 102332], - [1.413013, 105500], - [1.376677, 108437], - [1.333553, 111092], - [1.284381, 113421], - [1.23, 115383], - [1.171342, 116945], - [1.10941, 118080], - [1.045263, 118769], - [0.98, 119000], - [0.914737, 118769], - [0.85059, 118080], - [0.788658, 116945], - [0.73, 115383], - [0.675619, 113421], - [0.626447, 111092], - [0.583323, 108437], - [0.546987, 105500], - [0.51806, 102332], - [0.497037, 98988], - [0.484278, 95524], - [0.48, 92000], - [0.484278, 88476], - [0.497037, 85012], - [0.51806, 81668], - [0.546987, 78500], - [0.583323, 75563], - [0.626447, 72908], - [0.675619, 70579], - [0.73, 68617], - [0.788658, 67055], - [0.85059, 65920], - [0.914737, 65231], - [0.98, 65000], - [1.045263, 65231], - [1.10941, 65920], - [1.171342, 67055], - [1.23, 68617], - [1.284381, 70579], - [1.333553, 72908], - [1.376677, 75563], - [1.413013, 78500], - [1.44194, 81668], - [1.462963, 85012], - [1.475722, 88476] - ] - }, - "peaks": [ - { - "name": "Eastwall", - "theta": 0.98, - "z": 92000, - "elevationM": 3550 - } - ], - "heightM": 3550, - "ruggedness": 0.55, - "snowlineM": 3000 - }, - { - "id": "the-girdle", - "name": "The Girdle", - "blurb": "", - "notes": "", - "public": true, - "seed": 1143607758, - "kind": "mountains", - "geometry": { - "type": "band", - "zFrom": 56000, - "zTo": 124000 - }, - "peaks": [], - "heightM": 2900, - "ruggedness": 0.6, - "snowlineM": 3000 - }, - { - "id": "hollow-tarn", - "name": "Hollow Tarn", - "blurb": "", - "notes": "", - "public": true, - "seed": 226829642, - "kind": "lake", - "geometry": { - "type": "polygon", - "points": [ - [0.26, 100000], - [0.259231, 100468], - [0.256955, 100918], - [0.253259, 101333], - [0.248284, 101697], - [0.242223, 101996], - [0.235307, 102217], - [0.227804, 102354], - [0.22, 102400], - [0.212196, 102354], - [0.204693, 102217], - [0.197777, 101996], - [0.191716, 101697], - [0.186741, 101333], - [0.183045, 100918], - [0.180769, 100468], - [0.18, 100000], - [0.180769, 99532], - [0.183045, 99082], - [0.186741, 98667], - [0.191716, 98303], - [0.197777, 98004], - [0.204693, 97783], - [0.212196, 97646], - [0.22, 97600], - [0.227804, 97646], - [0.235307, 97783], - [0.242223, 98004], - [0.248284, 98303], - [0.253259, 98667], - [0.256955, 99082], - [0.259231, 99532] - ] - }, - "levelM": "auto", - "depthM": 190, - "basin": "bowl", - "shoreM": 2000, - "rimM": 120 - }, - { - "id": "sisters-tarn", - "name": "Sisters Tarn", - "blurb": "", - "notes": "", - "public": true, - "seed": 1798023826, - "kind": "lake", - "geometry": { - "type": "polygon", - "points": [ - [-0.43, 104000], - [-0.430769, 104468], - [-0.433045, 104918], - [-0.436741, 105333], - [-0.441716, 105697], - [-0.447777, 105996], - [-0.454693, 106217], - [-0.462196, 106354], - [-0.47, 106400], - [-0.477804, 106354], - [-0.485307, 106217], - [-0.492223, 105996], - [-0.498284, 105697], - [-0.503259, 105333], - [-0.506955, 104918], - [-0.509231, 104468], - [-0.51, 104000], - [-0.509231, 103532], - [-0.506955, 103082], - [-0.503259, 102667], - [-0.498284, 102303], - [-0.492223, 102004], - [-0.485307, 101783], - [-0.477804, 101646], - [-0.47, 101600], - [-0.462196, 101646], - [-0.454693, 101783], - [-0.447777, 102004], - [-0.441716, 102303], - [-0.436741, 102667], - [-0.433045, 103082], - [-0.430769, 103532] - ] - }, - "levelM": "auto", - "depthM": 190, - "basin": "bowl", - "shoreM": 2000, - "rimM": 120 - }, - { - "id": "west-tarn", - "name": "Cold Tarn", - "blurb": "", - "notes": "", - "public": true, - "seed": 1277495802, - "kind": "lake", - "geometry": { - "type": "polygon", - "points": [ - [-1.01, 98000], - [-1.010769, 98468], - [-1.013045, 98918], - [-1.016741, 99333], - [-1.021716, 99697], - [-1.027777, 99996], - [-1.034693, 100217], - [-1.042196, 100354], - [-1.05, 100400], - [-1.057804, 100354], - [-1.065307, 100217], - [-1.072223, 99996], - [-1.078284, 99697], - [-1.083259, 99333], - [-1.086955, 98918], - [-1.089231, 98468], - [-1.09, 98000], - [-1.089231, 97532], - [-1.086955, 97082], - [-1.083259, 96667], - [-1.078284, 96303], - [-1.072223, 96004], - [-1.065307, 95783], - [-1.057804, 95646], - [-1.05, 95600], - [-1.042196, 95646], - [-1.034693, 95783], - [-1.027777, 96004], - [-1.021716, 96303], - [-1.016741, 96667], - [-1.013045, 97082], - [-1.010769, 97532] - ] - }, - "levelM": "auto", - "depthM": 190, - "basin": "bowl", - "shoreM": 2000, - "rimM": 120 - }, - { - "id": "east-tarn", - "name": "Long Tarn", - "blurb": "", - "notes": "", - "public": true, - "seed": 1335790732, - "kind": "lake", - "geometry": { - "type": "polygon", - "points": [ - [0.98, 103000], - [0.979231, 103468], - [0.976955, 103918], - [0.973259, 104333], - [0.968284, 104697], - [0.962223, 104996], - [0.955307, 105217], - [0.947804, 105354], - [0.94, 105400], - [0.932196, 105354], - [0.924693, 105217], - [0.917777, 104996], - [0.911716, 104697], - [0.906741, 104333], - [0.903045, 103918], - [0.900769, 103468], - [0.9, 103000], - [0.900769, 102532], - [0.903045, 102082], - [0.906741, 101667], - [0.911716, 101303], - [0.917777, 101004], - [0.924693, 100783], - [0.932196, 100646], - [0.94, 100600], - [0.947804, 100646], - [0.955307, 100783], - [0.962223, 101004], - [0.968284, 101303], - [0.973259, 101667], - [0.976955, 102082], - [0.979231, 102532] - ] - }, - "levelM": "auto", - "depthM": 190, - "basin": "bowl", - "shoreM": 2000, - "rimM": 120 - }, - { - "id": "the-dry-reach", - "name": "The Dry Reach", - "blurb": "", - "notes": "", - "public": true, - "seed": 227210026, - "kind": "desert", - "geometry": { - "type": "band", - "zFrom": 148000, - "zTo": 218000 - }, - "upliftM": 780, - "edgeM": 20000, - "dunes": { - "coverage": 0.5, - "heightM": 80, - "spacingM": 1100, - "directionDeg": 0 - }, - "hills": { - "coverage": 0.3, - "heightM": 160 - }, - "plateaus": { - "coverage": 0.28, - "heightM": 520, - "terraces": 3 - }, - "pastWater": { - "washes": 0.7, - "saltPans": 4, - "shorelines": true - } - }, - { - "id": "the-uplands", - "name": "The Uplands", - "blurb": "", - "notes": "", - "public": true, - "seed": 1990705996, - "kind": "region", - "geometry": { - "type": "band", - "zFrom": 105000, - "zTo": 150000 - }, - "upliftM": 1000, - "edgeM": 8000 - }, - { - "id": "first-ridge", - "name": "First Ridge", - "blurb": "", - "notes": "", - "public": true, - "seed": 1871446183, - "kind": "mountains", - "geometry": { - "type": "band", - "zFrom": 162000, - "zTo": 190000 - }, - "peaks": [], - "heightM": 260, - "ruggedness": 0, - "snowlineM": 3000 - }, - { - "id": "second-ridge", - "name": "Second Ridge", - "blurb": "", - "notes": "", - "public": true, - "seed": 1668141571, - "kind": "mountains", - "geometry": { - "type": "band", - "zFrom": 177000, - "zTo": 203000 - }, - "peaks": [], - "heightM": 300, - "ruggedness": 0, - "snowlineM": 3000 - }, - { - "id": "third-ridge", - "name": "Third Ridge", - "blurb": "", - "notes": "", - "public": true, - "seed": 540879940, - "kind": "mountains", - "geometry": { - "type": "band", - "zFrom": 188000, - "zTo": 218000 - }, - "peaks": [], - "heightM": 240, - "ruggedness": 0, - "snowlineM": 3000 - }, - { - "id": "westwall-water", - "name": "Westwall Water", - "blurb": "", - "notes": "", - "public": true, - "seed": 1255511300, - "kind": "river", - "geometry": { - "type": "polyline", - "points": [ - [-1.06, 104000], - [-1.02, 118000], - [-0.92, 132000], - [-0.8, 144000], - [-0.66, 152000], - [-0.52, 155500], - [-0.4, 158500], - [-0.28, 157500], - [-0.16, 160000], - [-0.06, 161000], - [0.062, 162000] - ] - }, - "flowM3S": 98, - "sourceM": 1980, - "outlet": "auto" - }, - { - "id": "sisters-water", - "name": "Sisters Water", - "blurb": "", - "notes": "", - "public": true, - "seed": 654450092, - "kind": "river", - "geometry": { - "type": "polyline", - "points": [ - [-0.47, 110000], - [-0.45, 124000], - [-0.4, 138000], - [-0.33, 148000], - [-0.24, 154000], - [-0.14, 157500], - [-0.04, 160000], - [0.062, 162000] - ] - }, - "flowM3S": 98, - "sourceM": 1900, - "outlet": "auto" - }, - { - "id": "eastwall-water", - "name": "Eastwall Water", - "blurb": "", - "notes": "", - "public": true, - "seed": 856398702, - "kind": "river", - "geometry": { - "type": "polyline", - "points": [ - [0.95, 108000], - [0.92, 122000], - [0.86, 134000], - [0.78, 145000], - [0.66, 152000], - [0.54, 155000], - [0.42, 158000], - [0.3, 157000], - [0.2, 159500], - [0.12, 161000], - [0.062, 162000] - ] - }, - "flowM3S": 98, - "sourceM": 1960, - "outlet": "auto" - }, - { - "id": "the-long-water", - "name": "The Long Water", - "blurb": "", - "notes": "", - "public": true, - "seed": 2105334655, - "kind": "river", - "geometry": { - "type": "polyline", - "points": [ - [0.22, 100000], - [0.195, 112000], - [0.17, 126000], - [0.14, 140000], - [0.1, 152000], - [0.062, 162000], - [0.03, 170000], - [-0.01, 178000], - [0.015, 186000], - [-0.02, 194000], - [0.005, 201000], - [-0.015, 208000], - [0.01, 216000], - [0.048, 226000], - [0.086, 236000], - [0.12, 246000] - ] - }, - "flowM3S": 98, - "sourceM": 1900, - "outlet": "auto" - }, - { - "id": "the-narrows", - "name": "The Narrows", - "blurb": "", - "notes": "", - "public": true, - "seed": 1630660453, - "kind": "landmark", - "geometry": { - "type": "polyline", - "points": [ - [0.03, 170000], - [-0.01, 178000], - [0.015, 186000], - [-0.02, 194000], - [0.005, 201000], - [-0.015, 208000] - ] - } - }, - { - "id": "greatwood", - "name": "Greatwood", - "blurb": "", - "notes": "", - "public": true, - "seed": 756694743, - "kind": "forest", - "geometry": { - "type": "band", - "zFrom": 214000, - "zTo": 430000 - }, - "upliftM": 250, - "edgeM": 30000 - }, - { - "id": "the-deep", - "name": "The Deep", - "blurb": "", - "notes": "", - "public": true, - "seed": 1519613949, - "kind": "lake", - "geometry": { - "type": "polygon", - "points": [ - [0.06, 248000], - [0.7, 248000], - [0.7, 402000], - [0.06, 402000] - ] - }, - "levelM": 120, - "depthM": 330, - "basin": "flooded", - "shoreM": 11000, - "rimM": 0 - }, - { - "id": "the-capital", - "name": "The Capital", - "blurb": "", - "notes": "", - "public": true, - "seed": 1711426401, - "kind": "settlement", - "geometry": { - "type": "point", - "theta": 0.04, - "z": 270000 - }, - "size": "city", - "radiusM": 10000 - }, - { - "id": "the-spindle", - "name": "The Spindle", - "blurb": "", - "notes": "", - "public": true, - "seed": 554690230, - "kind": "tower", - "geometry": { - "type": "point", - "theta": 0.02, - "z": 271500 - }, - "heightM": 3200 - }, - { - "id": "sisters-station", - "name": "Sisters Station", - "blurb": "", - "notes": "", - "public": true, - "seed": 1005547319, - "kind": "settlement", - "geometry": { - "type": "point", - "theta": -0.47, - "z": 110500 - }, - "size": "station" - }, - { - "id": "hollow-station", - "name": "Hollow Station", - "blurb": "", - "notes": "", - "public": true, - "seed": 613188767, - "kind": "settlement", - "geometry": { - "type": "point", - "theta": 0.235, - "z": 102000 - }, - "size": "station" - }, - { - "id": "the-tall-wood", - "name": "The Tall Wood", - "blurb": "", - "notes": "", - "public": true, - "seed": 1846203771, - "kind": "forest", - "geometry": { - "type": "polygon", - "points": [ - [ - 6.04707, - 282963 - ], - [ - 6.07639, - 282501 - ], - [ - 6.11914, - 282453 - ], - [ - 6.13135, - 280578 - ], - [ - 6.11988, - 278826 - ], - [ - 6.1282, - 276867 - ], - [ - 6.08876, - 276340 - ], - [ - 6.06402, - 275222 - ], - [ - 6.02828, - 274789 - ], - [ - 5.9941, - 275563 - ], - [ - 5.97356, - 277086 - ], - [ - 5.97544, - 278832 - ], - [ - 5.96794, - 280494 - ], - [ - 5.97979, - 282237 - ], - [ - 6.01586, - 282713 - ] - ] - }, - "upliftM": 0, - "edgeM": 900, - "densityPerHa": 30, - "conifer": 1, - "meadows": 0.05, - "giants": true, - "treelineM": 2200 - } - ], - "weather": { - "cycleDays": 28, - "events": [ - { - "id": "sunward-sea-squall", - "target": "sunward-sea", - "kind": "gale", - "start": { - "day": 11, - "hour": 9 - }, - "durationH": 5, - "wind": { - "directionDeg": 120, - "speedMS": 22 - }, - "publishedAt": { - "day": 11, - "hour": 8 - }, - "revisions": [] - }, - { - "id": "sunward-sea-storm", - "target": "sunward-sea", - "kind": "storm", - "start": { - "day": 3, - "hour": 6 - }, - "durationH": 18, - "wind": { - "directionDeg": 90, - "speedMS": 28 - }, - "publishedAt": { - "day": 1, - "hour": 12 - }, - "revisions": [] - }, - { - "id": "deepward-sea-gale", - "target": "deepward-sea", - "kind": "gale", - "start": { - "day": 9, - "hour": 14 - }, - "durationH": 10, - "wind": { - "directionDeg": 270, - "speedMS": 20 - }, - "publishedAt": { - "day": 8, - "hour": 0 - }, - "revisions": [] - }, - { - "id": "sunward-sea-calm", - "target": "sunward-sea", - "kind": "calm", - "start": { - "day": 15, - "hour": 0 - }, - "durationH": 24, - "wind": { - "directionDeg": 90, - "speedMS": 1 - }, - "publishedAt": { - "day": 13, - "hour": 9 - }, - "revisions": [] - }, - { - "id": "deepward-sea-storm", - "target": "deepward-sea", - "kind": "storm", - "start": { - "day": 20, - "hour": 18 - }, - "durationH": 30, - "wind": { - "directionDeg": 60, - "speedMS": 30 - }, - "publishedAt": { - "day": 17, - "hour": 9 - }, - "revisions": [ - { - "at": { - "day": 21, - "hour": 6 - }, - "kind": "gale", - "wind": { - "directionDeg": 60, - "speedMS": 18 - }, - "reason": "A fishing boat that ignored the plan is still out. Tower is holding the storm down to a gale until it is in." - } - ] - }, - { - "id": "the-deep-gale", - "target": "the-deep", - "kind": "gale", - "start": { - "day": 24, - "hour": 10 - }, - "durationH": 8, - "wind": { - "directionDeg": 0, - "speedMS": 18 - }, - "publishedAt": { - "day": 23, - "hour": 10 - }, - "revisions": [] - } - ] - } -} diff --git a/test/world.test.ts b/test/world.test.ts index d03b891..6e6389a 100644 --- a/test/world.test.ts +++ b/test/world.test.ts @@ -1,10 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { - type Feature, FEATURE_KINDS, anchorOf, geometryTypesFor, isAreaKind, isAutoId, isLineKind, + type Feature, FEATURE_KINDS, anchorOf, geometryTypesFor, isAreaKind, isLineKind, isTracedKind, makeId, parseWorld, terrainSignature, toPublicWorld, withKind, } from '../src/world/schema.js' import { readFileSync } from 'node:fs' -import { WorldStore, backupFilename } from '../src/world/store.js' const place = (over: Partial<Feature> = {}): Feature => ({ ...withKind({ @@ -24,22 +23,6 @@ describe('ids', () => { }) }) -describe('isAutoId decides whether an id should follow a rename', () => { - it('recognises a plain slug of the name', () => { - expect(isAutoId('new-city', 'New city')).toBe(true) - expect(isAutoId('hallow-crossing', 'Hallow Crossing')).toBe(true) - }) - it('recognises a de-duplicated slug', () => { - expect(isAutoId('new-city-2', 'New city')).toBe(true) - expect(isAutoId('new-city-17', 'New city')).toBe(true) - }) - it('leaves a hand-pinned id alone', () => { - expect(isAutoId('rimfall-permanent', 'Rimfall')).toBe(false) - expect(isAutoId('new-city-x', 'New city')).toBe(false) - expect(isAutoId('anything', 'New city')).toBe(false) - }) -}) - describe('parseWorld tolerates hand-edited files', () => { it('survives junk without throwing', () => { for (const junk of [null, 42, {}, { features: 'no' }, { features: [null, 7] }]) { @@ -152,11 +135,11 @@ describe('the terrain signature', () => { describe('the committed world loads without losing anything', () => { const full = JSON.parse(readFileSync('content/world.json', 'utf8')) - const pub = JSON.parse(readFileSync('static/world.json', 'utf8')) + // What the build serves as world.json (vite.config.ts): made from the file, never kept. + const pub = JSON.parse(JSON.stringify(toPublicWorld(parseWorld(full)))) - it('keeps every feature the port wrote', () => { + it('keeps every feature in the file', () => { expect(parseWorld(full).features).toHaveLength(full.features.length) - expect(parseWorld(pub).features).toHaveLength(pub.features.length) }) it('knows every kind and geometry in it', () => { for (const f of full.features) { @@ -175,9 +158,10 @@ describe('the committed world loads without losing anything', () => { expect(byName.get(n)?.geometry.type).toBe('band') } }) - it('publishes the same world, less its notes', () => { + it('publishes every public feature, less its notes', () => { expect(JSON.stringify(pub)).not.toContain('Whoever holds The Narrows') - expect(terrainSignature(parseWorld(pub))).toBe(terrainSignature(parseWorld(full))) + expect(parseWorld(pub).features.map((f) => f.id)) + .toEqual(full.features.filter((f: Feature) => f.public).map((f: Feature) => f.id)) }) }) @@ -197,120 +181,3 @@ describe('the public export is the privacy boundary', () => { expect(json).toContain('visible') }) }) - -describe('WorldStore', () => { - let store: WorldStore - beforeEach(() => { - const data = new Map<string, string>() - vi.stubGlobal('localStorage', { - getItem: (k: string) => data.get(k) ?? null, - setItem: (k: string, v: string) => void data.set(k, v), - removeItem: (k: string) => void data.delete(k), - }) - store = new WorldStore() - }) - - it('persists across a reload', async () => { - store.add(place({ id: 'a', name: 'Kept' })) - store.flush() - const fresh = new WorldStore() - await fresh.load('/world.json') - expect(fresh.features.map((p) => p.name)).toEqual(['Kept']) - }) - - it('treats an empty draft as no draft and loads the seed instead', async () => { - // Opening the app once while the seed was empty used to persist an empty world - // that then shadowed the real one forever. - store.replaceAll({ version: 2, seed: 1, ground: { baseM: 0, reliefM: 0, notes: '' }, features: [], weather: { cycleDays: 0, events: [] } }) - store.flush() - const fresh = new WorldStore() - vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({ - version: 2, features: [place({ id: 'seed', name: 'Seed' })] }) })) - await fresh.load('/world.json') - expect(fresh.features.map((p) => p.name)).toEqual(['Seed']) - }) - - it('reseeds on demand, replacing whatever is there', async () => { - store.add(place({ id: 'local', name: 'Local' })) - vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({ - version: 2, features: [place({ id: 'a', name: 'A' }), place({ id: 'b', name: 'B' })] }) })) - await expect(store.seedFrom('/world.json')).resolves.toBe(2) - expect(store.features.map((p) => p.name)).toEqual(['A', 'B']) - }) - - it('prefers the local draft over the committed seed', async () => { - store.add(place({ id: 'draft', name: 'Draft' })) - store.flush() - const fresh = new WorldStore() - vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({ - version: 2, features: [place({ id: 'seed', name: 'Seed' })] }) })) - await fresh.load('/world.json') - expect(fresh.features.map((p) => p.name)).toEqual(['Draft']) - }) - - it('seeds from the committed world when there is no draft', async () => { - const fresh = new WorldStore() - vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({ - version: 2, features: [place({ id: 'seed', name: 'Seed' })] }) })) - await fresh.load('/world.json') - expect(fresh.features.map((p) => p.name)).toEqual(['Seed']) - }) - - it('starts empty rather than failing when there is no seed file', async () => { - const fresh = new WorldStore() - vi.stubGlobal('fetch', async () => { throw new Error('404') }) - await expect(fresh.load('/world.json')).resolves.toBeUndefined() - expect(fresh.features).toEqual([]) - }) - - it('reports rather than throws when localStorage is unavailable', () => { - vi.stubGlobal('localStorage', { - getItem: () => null, - setItem: () => { throw new Error('QuotaExceededError') }, - }) - const s = new WorldStore() - s.add(place()) - s.flush() - expect(s.saveState).toBe('error') - expect(s.storageError).toContain('Quota') - }) - - it('keeps everything in the backup and only public data in the static export', () => { - store.add(place({ id: 'a', name: 'Pub', notes: 'SECRET' })) - store.add(place({ id: 'b', name: 'Priv', public: false })) - expect(store.fullJSON()).toContain('SECRET') - expect(store.fullJSON()).toContain('Priv') - expect(store.publicJSON()).not.toContain('SECRET') - expect(store.publicJSON()).not.toContain('Priv') - expect(store.publicCount()).toBe(1) - }) - - it('keeps a river pointed at its outlet when the outlet is re-id-ed', () => { - store.add(withKind({ id: 'deep', name: 'Deep', geometry: { type: 'polygon', points: [[0, 0], [0.1, 0], [0.1, 1]] }, - blurb: '', notes: '', public: true, seed: 1 }, 'lake')) - store.add({ ...withKind({ id: 'r', name: 'R', geometry: { type: 'polyline', points: [[0, 0], [0, 1]] }, - blurb: '', notes: '', public: true, seed: 2 }, 'river'), outlet: 'deep' } as Feature) - store.setId('deep', 'the-deep') - const r = store.get('r')! - expect(r.kind === 'river' && r.outlet).toBe('the-deep') - }) - - it('renames ids, since they are permalink targets', () => { - store.add(place({ id: 'new-city', name: 'New city' })) - expect(store.setId('new-city', 'hallow-crossing')).toBe(true) - expect(store.get('hallow-crossing')?.name).toBe('New city') - expect(store.get('new-city')).toBeUndefined() - }) - - it('refuses an id collision rather than losing a feature', () => { - store.add(place({ id: 'a' })) - store.add(place({ id: 'b' })) - expect(store.setId('a', 'b')).toBe(false) - expect(store.features).toHaveLength(2) - expect(store.get('a')).toBeDefined() - }) - - it('dates backup filenames so a schedule of them sorts', () => { - expect(backupFilename(new Date('2026-09-05T12:00:00Z'))).toBe('tower-backup-2026-09-05.json') - }) -}) diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 843d177..84c50fd 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -523,18 +523,17 @@ const SHOTS = [ }, }, { - id: 'reader', - caption: 'Reader mode (?reader=1) — terrain, features, a detail card and the probe, no editing', + id: 'detail', + caption: 'A place selected — its detail card, with what the world made of it', on: ['desktop'], run: async (page) => { await openView(page, 'map') - await pick(page, 'The Spindle') + await pick(page, 'The Dry Reach') await settle(page) }, }, ] -/** Reader mode is a query parameter, so it has to be chosen before the page loads. */ /** * Every page asks for two terrain workers and the compiled-world cache. Several pages run * at once, and each opening a full pool of six and compiling the whole world — erosion @@ -542,7 +541,7 @@ const SHOTS = [ * no shot compiles at all. */ const TOUR_PARAMS = 'workers=2&cache=1' -const searchFor = (shot) => (shot.id === 'reader' ? `?reader=1&${TOUR_PARAMS}` : `?${TOUR_PARAMS}`) +const searchFor = () => `?${TOUR_PARAMS}` /** The storage every shot's fresh context starts from: the warm cache, and nothing else. */ let warmStorage = undefined diff --git a/vite.config.ts b/vite.config.ts index 678d2d8..9cb4ae8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,39 @@ -import { defineConfig } from 'vite' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { type Plugin, defineConfig } from 'vite' import { qrcode } from 'vite-plugin-qrcode' +import { parseWorld, toPublicWorld } from './src/world/schema.js' + +/** + * `content/world.json` is the world, and the only copy of it (D-072). Readers get it + * less its notes and its unpublished features, and that file is made from it here — + * served at `world.json` in dev, emitted into the build — rather than committed beside + * it, so the two can never disagree. A change to the file reloads an open dev page. + */ +const WORLD = path.resolve('content/world.json') +const publicWorld = (): string => + JSON.stringify(toPublicWorld(parseWorld(JSON.parse(readFileSync(WORLD, 'utf8')))), null, 2) + +function world(): Plugin { + return { + name: 'tower-world', + configureServer(server) { + server.watcher.add(WORLD) + server.watcher.on('change', (file) => { + if (path.resolve(file) === WORLD) server.ws.send({ type: 'full-reload' }) + }) + server.middlewares.use((req, res, next) => { + if (req.url?.split('?')[0] !== '/world.json') return next() + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'no-store') + res.end(publicWorld()) + }) + }, + generateBundle() { + this.emitFile({ type: 'asset', fileName: 'world.json', source: publicWorld() }) + }, + } +} /** * Relative asset paths by default, so the same build works wherever Pages puts it. @@ -19,7 +53,7 @@ export default defineConfig({ base, // `npm run dev` binds to every interface (--host) so a phone on the same Wi-Fi // can open the app; this prints a scannable QR of that network URL alongside it. - plugins: [qrcode()], + plugins: [qrcode(), world()], // Vite's static directory is `static/`, not the default `public/`, because // GitLab Pages requires its artifact to be a directory named `public` — and // `mv dist public` does not rename when `public/` already exists, it moves dist diff --git a/world-generation.md b/world-generation.md index e976ca5..96db9da 100644 --- a/world-generation.md +++ b/world-generation.md @@ -270,8 +270,8 @@ interface WeatherEvent { - **Private features vanish from readers' terrain too.** A `public: false` lake is stripped from the reader's file (D-011), so readers do not get its basin either. That is the correct behaviour — a hole in the ground shaped like an unpublished - lake would leak it — but it means the author's and the reader's world differ, - and the writer UI should say so. + lake would leak it. Since D-072 the author uses the same app readers do, so an + unpublished feature exists only in `content/world.json` until it is published. --- @@ -350,7 +350,7 @@ the water surface and the terrain under it. descend (§1.9.3), and it runs in a closed pumped or weather-driven loop (§1.9.5). So the generator's one hard rule is: **the water surface descends monotonically from source to outlet.** If the terrain along the drawn course would make the river flow -uphill, the valley is cut deeper until it does not — and the editor reports how +uphill, the valley is cut deeper until it does not — and the compile reports how much it had to cut, because that is information the author wants. **Generation.** @@ -531,9 +531,9 @@ ground, fresh water within 2 km, not in the desert core, not above the treeline accepted largest first with a minimum spacing that grows with size. Generated settlements are **named for their size** — "Hamlet", "Village", "Town" — and marked as generated on the map (author, 2026-09-16). Several share a name, which is correct: -nobody has named them yet. The editor's -**Adopt** turns one into an authored feature, which pins it: after that, editing the -world elsewhere cannot move or remove it. +nobody has named them yet. **Adopting** one — writing it into `content/world.json` as an +authored feature — pins it: after that, changing the world elsewhere cannot move or +remove it. Layout, for every size: @@ -689,12 +689,12 @@ Where the numbers come from: | Seed | Set when | Changes when | |---|---|---| -| `world.seed` | once, by the port script, to a fixed value | only if the author deliberately rerolls the whole world (a button in writer mode, with a confirmation) | -| `feature.seed` | once, when the author creates the feature — the one moment a random number is drawn — and saved with it | only by the author pressing **Reroll** on that feature | +| `world.seed` | once, by the port script, to a fixed value | only if the author deliberately rerolls the whole world | +| `feature.seed` | once, when the feature is written into the file — the one moment a random number is drawn — and saved with it | only if the author asks for that feature to be rerolled | | everything generated | never stored; recomputed from the two seeds above and the feature's parameters | whenever those change | -The author's local draft (localStorage) carries its seeds, and the exported public -file carries the same seeds, so what the author sees is exactly what readers get. +`content/world.json` carries the seeds, and the `world.json` the app loads is made from +it with the same seeds, so what the author sees is exactly what readers get. Placements made "at random" — generated peaks, generated settlements, the Tall Wood's position — are random in the sense of *chosen by the seed*, not *different each time*. @@ -888,7 +888,7 @@ estimated at 2–3× the desktop time, 10–20 s, which the status dialog carrie - **Map.** Draws a hillshaded, material-coloured image of the **coarse world field**, produced live by the compile, with feature geometry drawn over it as vectors. It is still an image on screen, but it is a view generated from the features, not a - painted source of truth. Writer tools edit the vectors. + painted source of truth. The vectors are changed in the world file. - **Exterior.** The x-ray reading uses the same coarse field as its texture. Looking in through the windows (D-060) uses only the coarsest tiles, since nothing finer is resolvable through 100 km of glass and haze. @@ -897,26 +897,31 @@ estimated at 2–3× the desktop time, 10–20 s, which the status dialog carrie ## 10. Editing -Writer mode (the default; `?reader=1` turns it off) gains a tool per concept: +*Rewritten for D-072.* The world is edited in **`content/world.json`**, not in the +browser: the author describes a change, Claude makes it in the file, and the app +regenerates from it. There is no writer mode, no local draft and no export. With +`npm run dev` running, saving the file reloads the open page; the published site +changes when the commit is pushed. The app loads `world.json`, which `vite.config.ts` +makes from the file with every `notes` emptied and unpublished features left out +(design.md §3.1). -| Concept | Placing | Sub-tools | +What each concept is authored as — the parameters a change touches: + +| Concept | Shape | Parameters | |---|---|---| -| Mountains | draw area (polygon or band), optional spine | add / drag / delete peak, set elevation | -| Lake | draw shore | level (auto or number), wind | -| River | draw course, source to mouth | flow; outlet shown and overridable; warning if valley had to be over-cut | -| Sea | draw band or polygon | level, wind, ships | -| Desert | draw area | dunes, hills, plateaus, past water sliders | -| Forest | draw area | draw stand, draw clearing, species, density, path density, giants | -| Settlement | click centre; size; radius or boundary | pattern, arterials, block size, building sliders; **Adopt** on a generated one | -| Weather | pick a sea, lake or desert | add event, revise event (with a reason), baseline wind | -| Road / path | draw, or pick two places to route | width | - -Each edit goes: store → autosave (unchanged) → incremental compile → map preview -updates → affected tiles regenerate in the interior. The map shows a small -"generating…" indicator with the step in progress. - -Export is unchanged in spirit: the full file for backup, the public file with private -features and all `notes` stripped for the repo (D-011). +| Mountains | polygon or band | peaks (name, position, elevation), generated-summit height and count, ruggedness, erosion, snowline | +| Lake | polygon (the shore) | level (auto or a number), depth, basin, shore, rim, wind | +| River | polyline, source to mouth | flow, source, outlet (by id, or auto) | +| Sea | band or polygon | level, depth, shore, wind, ships | +| Desert | polygon or band | uplift, edge, dunes, hills, plateaus, past water | +| Forest | polygon or band | uplift, edge, density, conifer share, meadows, treeline, giants | +| Settlement | point | size, radius; a generated one is **adopted** by writing it in | +| Weather | the plan's events | target, kind, start, duration, wind, when published, revisions with reasons; the cycle | +| Road / path | polyline | width | + +Each change goes: file → compile (only what the change touches, §5.2) → map and +interior regenerate. A change that does not touch the ground — a name, a blurb, a +note, a wind — regenerates nothing. ### 10.1 The status dialog @@ -1119,7 +1124,8 @@ real ranges: A cirque is ground the rivers did not cut down, so each authored bowl lake keeps its range at the uncut height around it. Sisters Tarn resolves to 2,173 m, beside the ski station design.md puts at 2,100 m. -- **Editing.** Summits are listed in the editor with name and elevation, added by clicking +- **Editing.** *(The editor was removed by D-072; summits are now edited in the file.)* + Summits are listed in the editor with name and elevation, added by clicking the map and dragged to move; the range's generated-summit height, count, ruggedness, erosion and snowline are controls. Every change regenerates the range, and the service now runs **one compile at a time**, collapsing edits made during it into the next: a @@ -1232,7 +1238,8 @@ draws in about 1.2 s. next fortnight, the one in force highlighted, revisions listed under their event with the original struck through. Readers see only what has been published. Writers can schedule an event (published the moment it is added), revise one from now with a reason, delete - one, and set the cycle. A selected lake or sea's readout shows its waves *now*. + one, and set the cycle *(removed by D-072: the plan is edited in the file, and the board + shows only what has been published, to everyone)*. A selected lake or sea's readout shows its waves *now*. --- @@ -1315,7 +1322,8 @@ find them, and a screenshot at a fixed place was a poor way to show them. steep desert face** — sand does not stand at 60°. The ground shader lays **strata** by elevation, so the bands line up across separate mesas however far apart they stand, which is what makes a field of them read as one eroded formation. -- **The panel edits all of it**: dune height, spacing, direction and coverage; hills; +- **The panel edits all of it** *(until D-072; now the file does, and the detail card + keeps the readout)*: dune height, spacing, direction and coverage; hills; plateau height, coverage and terraces; washes, pans and shorelines — with a readout of what was actually made ("24% standing rock, 37% dune field, 4 salt pans"). - **The tour gained two scenes** (§19): past the mesas from the sand, and over a salt pan