settlemaker 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +162 -277
  2. package/dist/output/geojson-builder.d.ts +1 -1
  3. package/dist/output/geojson-builder.js +1 -1
  4. package/dist/settlemaker.browser.js +1 -1
  5. package/docs/README.md +54 -0
  6. package/docs/api.md +175 -0
  7. package/docs/artwork-integration.md +104 -0
  8. package/docs/current-symbol-library.md +62 -42
  9. package/docs/development.md +102 -0
  10. package/docs/examples/gallery/coastal-city.png +0 -0
  11. package/docs/examples/gallery/coastal-city.svg +4253 -0
  12. package/docs/examples/gallery/coastal-village.png +0 -0
  13. package/docs/examples/gallery/coastal-village.svg +1211 -0
  14. package/docs/examples/gallery/copperline-city.png +0 -0
  15. package/docs/examples/gallery/copperline-city.svg +1139 -0
  16. package/docs/examples/gallery/copperline-village.png +0 -0
  17. package/docs/examples/gallery/copperline-village.svg +2382 -0
  18. package/docs/examples/gallery/desert-city.png +0 -0
  19. package/docs/examples/gallery/desert-city.svg +3509 -0
  20. package/docs/examples/gallery/desert-village.png +0 -0
  21. package/docs/examples/gallery/desert-village.svg +455 -0
  22. package/docs/examples/gallery/fixtures.json +449 -0
  23. package/docs/examples/gallery/temperate-city-night.png +0 -0
  24. package/docs/examples/gallery/temperate-city-night.svg +3511 -0
  25. package/docs/examples/gallery/temperate-city.png +0 -0
  26. package/docs/examples/gallery/temperate-city.svg +3511 -0
  27. package/docs/examples/gallery/temperate-village.png +0 -0
  28. package/docs/examples/gallery/temperate-village.svg +3227 -0
  29. package/docs/examples/gallery/tropical-city.png +0 -0
  30. package/docs/examples/gallery/tropical-city.svg +3508 -0
  31. package/docs/examples/gallery/tropical-village.png +0 -0
  32. package/docs/examples/gallery/tropical-village.svg +3761 -0
  33. package/docs/examples/gallery/tundra-city.png +0 -0
  34. package/docs/examples/gallery/tundra-city.svg +3509 -0
  35. package/docs/examples/gallery/tundra-village.png +0 -0
  36. package/docs/examples/gallery/tundra-village.svg +1549 -0
  37. package/docs/fmg-embed-zoom.md +54 -0
  38. package/docs/gallery.md +139 -0
  39. package/docs/geojson.md +188 -0
  40. package/docs/getting-started.md +157 -0
  41. package/docs/landscape-character-review.md +55 -0
  42. package/docs/releases/3.0.1.md +18 -0
  43. package/docs/scene-schema.md +160 -0
  44. package/docs/schema-v3.md +38 -0
  45. package/docs/skins.md +52 -0
  46. package/docs/test-urls.md +39 -0
  47. package/docs/url-api.md +252 -0
  48. package/docs/water-context-v1.md +384 -0
  49. package/package.json +15 -8
  50. package/scripts/check-docs.mjs +106 -0
  51. package/scripts/generate-copperline.mjs +255 -0
  52. package/scripts/generate-examples.mjs +54 -0
  53. package/scripts/render-copperline.mjs +36 -0
  54. package/src/output/geojson-builder.ts +1 -1
  55. package/symbols/copperline/README.md +10 -3
@@ -0,0 +1,384 @@
1
+ # Water context v1 — implementation contract
2
+
3
+ Supported by the **3.0.x village library**, for populations 1–1,000, through
4
+ both direct generation and the compressed URL input. This is the normative
5
+ contract for coverage, precision, precedence and diagnostics. City calls reject
6
+ `waterContext`; legacy city polygons remain in city-local units.
7
+
8
+ The hosted renderer is a separate deployment. A host must display the library's
9
+ errors and warnings and verify the deployed version before enabling measured
10
+ inputs. See the [URL adapter contract](url-api.md) and [library API](api.md).
11
+
12
+ ## Scope and wire format
13
+
14
+ This implementation serves **villages, population 1–1000**. Add `waterContext`
15
+ to `AzgaarBurgInput` in a direct library call or the compressed `i=` payload. The enclosing URL payload
16
+ version remains `1`; the new object has its own version discriminator.
17
+
18
+ ```ts
19
+ type WaterContextV1 =
20
+ | {
21
+ version: 1;
22
+ status: 'measured';
23
+ coordinateSpace: 'burg-local-metres';
24
+ surveyRadiusM: number;
25
+ geometryErrorM: number; // declared maximum physical boundary approximation error, <= 0.5 m
26
+ omittedRivers?: Array<{ riverId?: string; reason: 'local-width-unavailable' }>;
27
+ bodies: Array<{
28
+ featureId?: string; // actual water feature, not the adjacent land feature
29
+ kind: 'ocean' | 'lake';
30
+ distanceM: number;
31
+ bearingDeg: number;
32
+ polygonIndices?: number[]; // indices into coastlineGeometry for this body
33
+ coastApproximation?: 'simple-local-coast'; // explicit opt-in; ocean only
34
+ }>;
35
+ }
36
+ | {
37
+ version: 1;
38
+ status: 'unknown-units';
39
+ sourceUnit: string;
40
+ };
41
+ ```
42
+
43
+ Origin is the **burg's actual position**, not its containing cell centre.
44
+ Positive x is east, positive y is south; bearings are clockwise from north.
45
+ `distanceM` is the shortest horizontal distance to a real shoreline segment;
46
+ `bearingDeg` points from the burg to that same closest shoreline point.
47
+ At exactly zero distance, use the local seaward/waterward normal as the bearing.
48
+ Distances must be finite and nonnegative; bearings must be finite in `[0, 360)`;
49
+ the measured survey radius must be finite and at least 3000 m. Unknown versions,
50
+ malformed objects and unsupported coordinate spaces are errors, not legacy input.
51
+ `geometryErrorM` must be finite, nonnegative and no greater than 0.5 m. It describes
52
+ approximation of the authoritative map boundary, not the real-world survey
53
+ accuracy of that boundary. `coastApproximation` is invalid for a lake.
54
+
55
+ When polygons are supplied, `polygonIndices` associates a body's summary with
56
+ its own polygons. This avoids comparing an ocean distance with a nearby river
57
+ bank. Indices must be distinct nonnegative integers within the polygon array;
58
+ one polygon index belongs to at most one ocean/lake body. Include references
59
+ for each reported body intersecting the surveyed disc; a missing reference is a
60
+ `water-context-conflict`. Farther bodies can omit references. Unreferenced water
61
+ polygons remain authoritative geometry (for example, a legacy river polygon),
62
+ but must not be used to validate an unrelated ocean/lake summary.
63
+
64
+ Feature summaries do not claim to describe a whole lake shape. Existing
65
+ `coastlineGeometry` supplies filled polygons; existing `rivers` supplies village
66
+ river surveys. In measured village mode both remain burg-local metres.
67
+
68
+ ## 1. Coverage without knowing the final frame
69
+
70
+ **FMG's default survey radius is 3000 metres**, centred on the burg. This is an
71
+ explicit protocol requirement, independent of population-based layout heuristics,
72
+ seed, harbour cells and FMG's grid spacing. A caller may survey a larger radius.
73
+
74
+ FMG must identify every ocean/lake water area intersecting that disc, including
75
+ any body containing the burg. `bodies: []` means none intersects it. Distance to
76
+ a water-cell centre is not a valid inclusion/exclusion test. Use actual feature
77
+ boundaries/intersection tests. If the burg itself is in water, explicit water polygons are required; a
78
+ land-origin distance/bearing approximation is not sufficient. Bodies farther
79
+ away may be listed as additional context, but their presence does not enlarge the survey or the rendered frame.
80
+
81
+ ### Authoritative geometry and precision
82
+
83
+ FMG must derive inclusion, distance, bearing and exported polygons from **the
84
+ same authoritative shoreline representation used to display/save the map**.
85
+ That representation includes coastline smoothing, seeded detail and rounding;
86
+ it is not interchangeable with raw packed-cell/Voronoi edges. Hidden layers
87
+ still have geographic geometry. Use a shared geometry service, not the presence
88
+ of currently visible SVG elements, as the source for both display and export.
89
+
90
+ For a loaded map, retain the saved displayed boundary as authoritative until an
91
+ explicit geometry edit/regeneration replaces it. Do not silently regenerate a
92
+ different shore just to export a burg. If unrounded curves become authoritative
93
+ in a later FMG change, display, save and export must all adopt them together.
94
+
95
+ Flatten curves adaptively with a physical error bound in metres. Allocate the
96
+ reported `geometryErrorM` budget across flattening, simplification, Boolean
97
+ operations and final coordinate rounding; the combined maximum boundary deviation
98
+ within coverage must remain <= 0.5 m and preserve topology. Fixed subdivisions
99
+ per curve are not an error bound. Perform survey intersection tests on the curves
100
+ or conservatively against their bounded approximation: an arc can enter the survey
101
+ disc even when its endpoints and control-cell centres are outside it.
102
+
103
+ Calculate scalar summaries from the **final serialized geometry's true shoreline**
104
+ where supplied, using its same body associations. For summaries whose real shore
105
+ lies outside the exported coverage, measure the same canonical full boundary;
106
+ do not compare that value against an artificial crop edge. The 1 m conflict
107
+ tolerance is only an internal payload-consistency check. It does not claim that
108
+ FMG's underlying fantasy-map coast is positioned to metre accuracy. For Borteland,
109
+ a 0.1-map-unit coordinate rounding step corresponds to 482.8 m; mixing rounded
110
+ and unrounded sources cannot be repaired by treating the difference as noise.
111
+
112
+ Use stable feature/polygon ordering. Cache the shared boundary and its spatial
113
+ index, invalidating them on map load, coastline edits, coastline-generation
114
+ settings changes and relevant geometry changes. Do not rescan every world feature
115
+ independently for every burg export.
116
+
117
+ ### Filled water versus real shores
118
+
119
+ For a geometry-backed survey, FMG supplies the complete union of ocean/lake water
120
+ inside the survey disc in `coastlineGeometry`. It may include full feature rings
121
+ or clip them against the surrounding square `[-R, R] × [-R, R]`, where
122
+ `R = surveyRadiusM`. That square encloses the disc. Preserve islands/land when
123
+ constructing the water union; do not encode holes as additional filled water
124
+ rings. The existing polygon format does not interpret rings as holes. Decompose
125
+ water around land holes into simple filled pieces when needed.
126
+
127
+ Settlemaker must preserve the **filled union** for water classification and use
128
+ its **exposed real shoreline** for shoreline strokes, nearest-bank/distance
129
+ queries, road setbacks, bridge landfalls, shore walks and dock placement. Filling
130
+ each simple piece is permitted only if the raster result has no gaps; stroking
131
+ each piece separately is not. Dissolve shared/overlapping internal boundaries
132
+ or preserve equivalent boundary provenance through geometry operations. Retain
133
+ land islands as holes in the resulting union. Body-specific summary checks use
134
+ exposed boundary segments attributable to that body's `polygonIndices`; internal
135
+ piece edges and edges hidden under other water are not shores. Do not match a
136
+ body summary to an unrelated river bank.
137
+
138
+ The declared survey-square closure is artificial even where it is exposed in
139
+ the clipped union. Exclude it from every real-bank query; do not just hide its
140
+ stroke. FMG must clip at the declared square when using this cropping convention,
141
+ not at arbitrary unmarked interior edges. A body covering the entire survey can
142
+ have no real shoreline within coverage. It still fills the region with water;
143
+ its crop must not become an apparent nearby coast, and the burg origin must not
144
+ be moved to manufacture a land site. A burg strictly inside water fails with
145
+ `water-context-conflict`; a burg exactly on a real shore is permitted.
146
+
147
+ These rules apply also where river-mouth polygons overlap ocean polygons. All
148
+ water must be combined consistently for physical bank queries; a seam between
149
+ water sources must not become a bridge endpoint or a road setback obstacle.
150
+
151
+ Settlemaker's responsibilities in measured mode:
152
+
153
+ 1. Bound water-dependent planning to the surveyed disc, including any detour or
154
+ apron search. Do not infer dry ground outside it from absent polygons.
155
+ 2. Keep every final frame corner within `surveyRadiusM - 1000` metres of the
156
+ original burg origin. This leaves a 1 km planning/geometry margin; it does not
157
+ set a minimum frame size or pull distant water into view. Artificial polygon
158
+ closure along the survey square stays outside the frame and margin.
159
+ 3. Validate the actual frame and planning bounds at runtime. If they cannot fit,
160
+ return `water-coverage-exceeded` with `requiredSurveyRadiusM`, rounded up to
161
+ the next 1000 m and sufficient for the larger planning bound and frame margin.
162
+ Do not silently crop occupied buildings, treat unsurveyed ground as land, or
163
+ retry with a population-based coastline. Show the coverage error and required
164
+ radius inside Settlemaker. A library caller or an explicit manual workflow can
165
+ supply a larger survey and rebuild the URL. **FMG does not automatically
166
+ resurvey in v1**; no preflight, iframe result transport or new endpoint is
167
+ provided. See section 4 for the actual URL consumer's error behavior.
168
+
169
+ The current 112-case village review has a largest frame-corner radius of about
170
+ 601 m. This supports a generous 3 km initial survey; it is not a proof that every
171
+ possible input fits. The runtime check is the guarantee.
172
+
173
+ `surveyRadiusM` describes coverage, never zoom level. A measured shoreline 11 km
174
+ away must remain off a normal hamlet tile.
175
+
176
+ ## 2. Input precedence
177
+
178
+ Validate the mode first. Invalid/unknown-version contexts must not quietly fall
179
+ through to legacy behavior.
180
+
181
+ | Input | Required interpretation |
182
+ | --- | --- |
183
+ | `waterContext` absent | Existing contract unchanged: valid nonempty `coastlineGeometry`, otherwise legacy `oceanBearing` fallback. Existing `rivers` remain additive. |
184
+ | `status: 'unknown-units'` | Suppress geographic ocean/lake fallback and report `water-units-unknown`. Do not pretend a physical survey occurred. Reject simultaneous metre-valued `coastlineGeometry` or `rivers` in this mode; a sender with a real conversion must use `measured`. |
185
+ | Measured context **and `coastlineGeometry` property present** | Those polygons are authoritative ocean/lake geometry for the survey. `[]` explicitly means no ocean/lake water in the surveyed disc. Never generate additional shores from body summaries or legacy bearings. |
186
+ | Measured context, polygon property omitted, `bodies: []` | Authoritatively no ocean/lake within the surveyed disc. Suppress `oceanBearing`; do not invent water outside the survey. |
187
+ | Measured context, polygon property omitted, one relevant ocean summary explicitly declaring `coastApproximation: "simple-local-coast"` and satisfying the eligibility rules below | Generate an approximate shoreline anchored at its measured nearest point/distance. No population-derived shore standoff. Mark the result `approximate`. |
188
+ | Measured context, polygon property omitted, any relevant lake, multiple bodies, complex coast, or ocean without the explicit eligible approximation declaration | Return `water-geometry-required`. Feature count alone cannot determine local shoreline topology. |
189
+
190
+ **Simple-coast eligibility is geometric, not a feature count.** FMG may declare
191
+ `simple-local-coast` only after inspecting the canonical coast across the survey:
192
+ there must be one simple open shoreline section, with the burg on land, one
193
+ landward side and one waterward side, and no nearby opposite shoreline. Islands,
194
+ peninsulas, multiple nearby shoreline sections, enclosed bays with opposing banks,
195
+ and other complex coast configurations require polygons even if they all belong
196
+ to one connected ocean feature. If FMG cannot establish eligibility, it sends
197
+ polygons; omission of the opt-in never authorizes guessing. Settlemaker validates
198
+ its structural prerequisites and relies on FMG's explicit geographic declaration.
199
+
200
+ For an accepted approximate coast, the global minimum distance to the **whole
201
+ generated shoreline**, including the interior of curved segments, must not be
202
+ less than the reported distance (apart from numerical tolerance <= 1 m). Checking
203
+ only the nearest-point anchor or sampled control vertices is insufficient. Seeded
204
+ detail must remain waterward of that constraint. Approximation can change the
205
+ shape of a simple coast, but cannot move water toward the burg.
206
+
207
+ A body whose nearest water is outside the bounded local planning area does not
208
+ need a generated shoreline. Omit its off-map geometry without changing the frame.
209
+
210
+ **Geometry beats scalar summaries.** If a polygon disagrees with its summary,
211
+ use the polygon without translation, scaling or a second generated shoreline,
212
+ and report `water-context-conflict`. Recompute visible-water and proximity facts
213
+ from the polygon. Differences within 1 m may be treated as numerical tolerance;
214
+ larger differences must be reported. A missing geometric feature in a purported
215
+ complete polygon survey is also a conflict, not permission to invent it.
216
+
217
+ `rivers` remain additive and are not erased by an empty **ocean/lake** survey.
218
+ Do not send a river twice through `rivers` and `coastlineGeometry`; include river
219
+ polygons in the latter only when that river is not also supplied as a survey.
220
+ `port` and `harbourSize` control infrastructure, never water visibility or distance.
221
+ A port without an accessible nearby real shoreline produces a nonfatal
222
+ `water-context-conflict` and no dock/jetty placement. Keep the settlement and water
223
+ at their supplied locations; do not pull a coast inward to satisfy `port`.
224
+
225
+ ## 3. Physical units and cities
226
+
227
+ FMG performs exactly one world-to-metre conversion:
228
+
229
+ ```text
230
+ metresPerMapUnit = distanceScale × kilometresPerSelectedUnit × 1000
231
+ localX = (worldX - burg.x) × metresPerMapUnit
232
+ localY = (worldY - burg.y) × metresPerMapUnit
233
+ ```
234
+
235
+ Use the selected unit, not an assumed kilometre scale. For the supplied Borteland
236
+ save: `3 × 1.609344 × 1000 = 4828.032` metres per map unit. Apply this same factor
237
+ to coordinates and lengths actually measured in map units, including shore
238
+ distances, polygon coordinates and coordinate-derived local river-bank spacing.
239
+ Do not apply it to values already stored in physical units.
240
+
241
+ **FMG `River.width` is mouth width in kilometres.** Convert that scalar with
242
+ `widthM = River.width * 1000`, independent of the selected map distance unit or
243
+ scale. `RiverReading.widthKm` has the same physical-unit meaning. Neither value
244
+ establishes the river width beside an upstream burg. Prefer local bank geometry
245
+ from the authoritative river representation, transformed consistently into
246
+ metres. Changing the display unit must not rescale an already kilometre-valued
247
+ river width.
248
+
249
+ Do not substitute mouth width for a missing local width silently. For measured
250
+ water v1, a `rivers` survey's `widthM` must be a locally established width; use
251
+ explicit local river polygons where the channel varies materially across the
252
+ village. If only mouth width is available, omit the unsupported local survey and
253
+ show a nonfatal `water-river-local-width-unavailable` warning inside Settlemaker.
254
+ FMG communicates that omission through optional `waterContext.omittedRivers`,
255
+ an array of `{ riverId?: string, reason: 'local-width-unavailable' }`. This is
256
+ not a declaration that the surrounding region has no river. Mouth-width-only
257
+ rendering, if introduced later, needs an explicit approximation contract.
258
+
259
+ For a custom unit, FMG must have a positive, explicit metres-per-unit conversion.
260
+ If none exists, send `status: 'unknown-units'` with its label. Omit metric geometry
261
+ and distance assertions. This is neither a zero-distance shore nor an empty
262
+ measured survey. Settlemaker must display the units diagnostic itself so the
263
+ iframe user can see why geographic water is unavailable. Do not silently assume custom units
264
+ are kilometres. Audit each measurement by its source unit; kilometre-valued
265
+ context windows and corridor distances also need the selected-unit conversion
266
+ when their underlying measurements were made in map coordinates.
267
+
268
+ **City scaling is owned by Settlemaker, not guessed by FMG.** FMG's future measured
269
+ city payload must also use metres. A city implementation must establish and
270
+ publish its physical `metresPerMeshUnit`, use it consistently for water, buildings,
271
+ roads, frame and output, and define its own survey envelope. A viewBox, SVG pixel
272
+ scale, Voronoi radius or `urbanDensity` is not that conversion.
273
+
274
+ Until that work exists, `waterContext` on population >1000 returns
275
+ `water-context-unsupported-engine`; it must never reinterpret metres as mesh
276
+ units. FMG does not enable this new mode for cities yet. Legacy city URLs keep
277
+ their existing contract and do not gain a geographic-accuracy guarantee from this
278
+ village rollout. This restriction is deliberate and visible, not a silent no-op.
279
+
280
+ ## 4. Output, transport and rollout requirements
281
+
282
+ **V1 remains URL-only. There is no automatic FMG resurvey or recovery.** FMG
283
+ cannot read a cross-origin iframe's library result. Do not depend on iframe DOM
284
+ access, polling its JavaScript, or a result listener that has not been specified.
285
+ No `postMessage` protocol is introduced by this contract.
286
+
287
+ Settlemaker must display errors and nonfatal warnings visibly **inside both the
288
+ embedded preview and the external-open page**. Errors replace the failed map;
289
+ nonfatal warnings accompany the rendered map. They must be readable on initial
290
+ load without hover, clicking or other iframe interaction: FMG's preview can have
291
+ pointer events disabled. Coverage errors include the required survey radius in
292
+ the visible explanation. Unknown units and omitted local river widths must be
293
+ identified as unavailable data, not presented as a verified dry landscape.
294
+
295
+ Also expose a machine-readable `waterContextResult` through the library generation
296
+ result for direct consumers and Settlemaker's own UI:
297
+
298
+ ```ts
299
+ interface WaterContextResult {
300
+ version: 1;
301
+ status: 'measured' | 'approximate' | 'unknown-units' | 'error';
302
+ issues: Array<{
303
+ code: 'water-coverage-exceeded' | 'water-geometry-required'
304
+ | 'water-context-conflict' | 'water-units-unknown'
305
+ | 'water-context-unsupported-engine' | 'water-context-invalid'
306
+ | 'water-river-local-width-unavailable';
307
+ requiredSurveyRadiusM?: number;
308
+ }>;
309
+ }
310
+ ```
311
+
312
+ `approximate` identifies a measured-distance ocean without explicit bank polygons.
313
+ A geometry conflict is an issue on a geometry-backed result, not permission to
314
+ switch modes. Coverage, required-geometry, unsupported-engine and invalid-context
315
+ errors do not return a falsely complete rendered map. Legacy input omits this
316
+ new result entirely. Fatal errors throw `WaterContextError`, carrying this result on
317
+ `error.waterContextResult`. Successful `generateSettlement` calls expose it as
318
+ `result.waterContextResult`; village models also expose it on the site. It does
319
+ not introduce an iframe message protocol or an HTTP API.
320
+
321
+ The host must preserve `waterContext` when building links. If compression is
322
+ unavailable or the encoded payload exceeds the URL budget, do **not** fall back
323
+ to a flat URL that drops measured/unknown state and restores `oceanBearing`.
324
+ Simplify polygons within the declared physical error budget without changing
325
+ local water topology or closing narrow channels, recompute summaries from the
326
+ final serialized boundary, and revalidate coverage/precision/URL size; otherwise report
327
+ that the measured preview cannot be encoded. The existing flat tier has no
328
+ representation for this mode.
329
+
330
+ Roll out in this order:
331
+
332
+ 1. Implement and test the Settlemaker decoder, water preparation, bounded planning,
333
+ precedence, canonical/union-boundary queries, precision, visible diagnostics
334
+ and output semantics described here.
335
+ 2. Publish/pin a release explicitly supporting **water-context v1 for villages**.
336
+ 3. Enable FMG's measured village payload against that release. Verify both preview
337
+ and external-open URLs. Keep city and legacy paths separate during rollout.
338
+
339
+ Older decoders ignore unrecognized fields; therefore merely adding this object
340
+ to an older deployed renderer's URL is **not** a compatibility check. Do not enable the FMG
341
+ sender before the supporting renderer is deployed.
342
+
343
+ ## 5. Shared acceptance fixtures
344
+
345
+ Both implementations must test:
346
+
347
+ - Tarrimas-Ha: approximately 11,115 m to sea → no sea in the hamlet frame, with
348
+ either an empty complete 3 km survey or that distant body listed additionally.
349
+ - Same burg with a 40 m shore → visible water at that physical location.
350
+ - A nearby portless beach → water without invented harbour infrastructure.
351
+ - Nearby finite lake geometry versus a distant lake in the FMG neighbourhood.
352
+ - Equivalent maps in km, miles and nautical miles; custom units with and without
353
+ an explicit conversion.
354
+ - Measured empty survey plus legacy bearing → no generated sea.
355
+ - Explicit polygon, omitted polygons and explicit `[]` as separate cases; polygon
356
+ versus summary conflict → polygon retained with a conflict issue.
357
+ - Ocean/lake emptiness does not erase an independently supplied river.
358
+ - Survey too small for the protocol, and a valid survey exceeded by actual layout
359
+ or planning → explicit errors, never a falsely dry region.
360
+ - Measured lake without sufficient geometry → `water-geometry-required`.
361
+ - New context on a city → unsupported-engine error; legacy city output unchanged.
362
+ - URL-budget/compression fallback cannot discard measured or unknown state.
363
+ - No-context URLs retain existing compatibility behavior.
364
+ - Real iframe and external-open flows for coverage errors, unknown units, geometry
365
+ conflicts and missing local river width: diagnostics visible without pointer
366
+ interaction; no implicit automatic resurvey. Library-only tests do not satisfy
367
+ this acceptance case.
368
+ - A curve with endpoints outside the survey but an interior arc entering it;
369
+ hidden coastlines, coastline edits/settings changes and an old loaded save.
370
+ Measurement and polygon export use the same displayed/saved geometry, within
371
+ the declared approximation budget after final serialization.
372
+ - An island surrounded by one ocean decomposed into filled pieces, a lake with an
373
+ island, overlapping river-mouth/ocean polygons, and water covering the entire
374
+ survey: no filled land island, internal stroke, crop-edge bank, false bridge
375
+ landfall or relocation of the burg origin.
376
+ - Peninsula and island cases with one ocean feature but multiple local shores
377
+ require polygons. Missing simple-coast opt-in fails; an eligible approximation
378
+ satisfies its minimum-distance bound along the whole generated curve.
379
+ - An upstream burg with a much wider river mouth: local geometry controls local
380
+ width. Converting kilometre-valued mouth width uses only `* 1000`; changing
381
+ display units does not change the value. Unavailable local width is reported.
382
+ - `port` with no accessible nearby shore: nonfatal conflict, no invented docks.
383
+ - Population 1000 versus 1001, malformed body/polygon references, and a URL
384
+ simplification attempt that would close a narrow land neck or water channel.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "settlemaker",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "type": "module",
5
- "description": "Medieval fantasy settlement map generator — TypeScript port of watabou's Medieval Fantasy City Generator",
5
+ "description": "Procedural village and city map engine with SVG, local GeoJSON, biome artwork and portable skins for Node.js and browsers",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "scripts": {
@@ -12,12 +12,14 @@
12
12
  "build:art": "node scripts/build-runtime-art.mjs",
13
13
  "review:art": "npm run build && node scripts/review-artwork.mjs",
14
14
  "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
15
- "build:lib": "esbuild src/index.ts --bundle --format=esm --target=es2022 --minify --outfile=dist/settlemaker.browser.js --banner:js=\"/* settlemaker — GPL-3.0-only. Corresponding source: https://github.com/barrulus/settlemaker */\"",
15
+ "build:lib": "esbuild src/index.ts --bundle --format=esm --target=es2022 --minify --outfile=dist/settlemaker.browser.js --banner:js=\"/* settlemaker \u2014 GPL-3.0-only. Corresponding source: https://github.com/barrulus/settlemaker */\"",
16
16
  "test": "vitest run",
17
17
  "test:watch": "vitest",
18
18
  "review:roads": "node scripts/run-road-review.mjs",
19
19
  "measure:city-glyphs": "esbuild scripts/measure-city-glyphs.ts --bundle --platform=node --format=cjs --packages=external --outfile=output/measure-city-glyphs.cjs && node output/measure-city-glyphs.cjs",
20
- "review:cities": "esbuild scripts/review-cities.ts --bundle --platform=node --format=cjs --packages=external --outfile=output/city-review-runner.cjs && node output/city-review-runner.cjs"
20
+ "review:cities": "esbuild scripts/review-cities.ts --bundle --platform=node --format=cjs --packages=external --outfile=output/city-review-runner.cjs && node output/city-review-runner.cjs",
21
+ "docs:images": "npm run build && node scripts/generate-examples.mjs",
22
+ "docs:check": "npm run build && npm run build:lib && node scripts/check-docs.mjs"
21
23
  },
22
24
  "license": "GPL-3.0-only",
23
25
  "author": "barrulus",
@@ -43,9 +45,6 @@
43
45
  "scripts/build-runtime-art.mjs",
44
46
  "scripts/art",
45
47
  "NOTICE",
46
- "docs/releases/3.0.0.md",
47
- "docs/skins.md",
48
- "docs/current-symbol-library.md",
49
48
  "docs/skins.schema.json",
50
49
  "docs/examples/moon-glass.skin.json",
51
50
  "docs/examples/copperline.skin.json",
@@ -59,7 +58,15 @@
59
58
  "symbols/landscape",
60
59
  "symbols/greens",
61
60
  "symbols/infrastructure",
62
- "symbols/markers"
61
+ "symbols/markers",
62
+ "docs/*.md",
63
+ "docs/releases/3.0.0.md",
64
+ "docs/releases/3.0.1.md",
65
+ "docs/examples/gallery",
66
+ "scripts/generate-examples.mjs",
67
+ "scripts/check-docs.mjs",
68
+ "scripts/generate-copperline.mjs",
69
+ "scripts/render-copperline.mjs"
63
70
  ],
64
71
  "dependencies": {
65
72
  "@types/geojson": "^7946.0.14",
@@ -0,0 +1,106 @@
1
+ /** Check public guidance against real files, declarations and compiled examples. */
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { execFileSync } from 'node:child_process';
8
+ import ts from 'typescript';
9
+ import { SETTLEMAKER_VERSION, SKIN_SLOTS, decodeBurgParam } from '../dist/index.js';
10
+
11
+ const root = fileURLToPath(new URL('../', import.meta.url));
12
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
13
+ const read = path => readFileSync(resolve(root, path), 'utf8');
14
+ const pages = ['README.md', 'docs/README.md', 'docs/getting-started.md', 'docs/api.md',
15
+ 'docs/geojson.md', 'docs/scene-schema.md', 'docs/url-api.md', 'docs/water-context-v1.md',
16
+ 'docs/skins.md', 'docs/current-symbol-library.md', 'docs/artwork-integration.md',
17
+ 'docs/fmg-embed-zoom.md', 'docs/development.md', 'docs/test-urls.md', 'docs/gallery.md',
18
+ 'docs/schema-v3.md', 'symbols/copperline/README.md', `docs/releases/${pkg.version}.md`];
19
+ const withoutCode = text => text.replace(/```[^\n]*\n[\s\S]*?```/g, '');
20
+ function anchors(path) {
21
+ const counts = new Map();
22
+ return new Set([...withoutCode(read(path)).matchAll(/^#{1,6}\s+(.+)$/gm)].map(([, heading]) => {
23
+ const slug = heading.toLowerCase().replace(/[^\p{L}\p{N}_\-\s]/gu, '').replace(/\s/g, '-');
24
+ const count = counts.get(slug) ?? 0;
25
+ counts.set(slug, count + 1);
26
+ return count ? `${slug}-${count}` : slug;
27
+ }));
28
+ }
29
+ let links = 0;
30
+ for (const page of pages) {
31
+ const text = withoutCode(read(page));
32
+ const targets = [...text.matchAll(/\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g), ...text.matchAll(/(?:src|href)="([^"]+)"/g)];
33
+ for (const [, target] of targets) {
34
+ let local;
35
+ if (target.startsWith('https://github.com/barrulus/settlemaker/blob/')) {
36
+ const match = target.match(/^https:\/\/github\.com\/barrulus\/settlemaker\/blob\/(v[^/]+)\/(.+)$/);
37
+ assert.ok(match, `Invalid source link in ${page}: ${target}`);
38
+ assert.equal(match[1], `v${pkg.version}`, `Wrong documentation tag in ${page}`);
39
+ local = resolve(root, match[2]);
40
+ } else if (target.startsWith('https://raw.githubusercontent.com/barrulus/settlemaker/')) {
41
+ const match = target.match(/^https:\/\/raw\.githubusercontent\.com\/barrulus\/settlemaker\/(v[^/]+)\/(.+)$/);
42
+ assert.ok(match, `Invalid image link in ${page}: ${target}`);
43
+ assert.equal(match[1], `v${pkg.version}`, `Wrong image tag in ${page}`);
44
+ local = resolve(root, match[2]);
45
+ } else if (/^(?:https?:|mailto:)/.test(target)) continue;
46
+ else local = target.startsWith('#') ? resolve(root, page) + target : resolve(root, dirname(page), target);
47
+ const [path, anchor] = local.split('#');
48
+ assert.ok(existsSync(decodeURIComponent(path)), `Broken link in ${page}: ${target}`);
49
+ if (anchor && path.endsWith('.md')) assert.ok(anchors(path).has(decodeURIComponent(anchor)), `Broken anchor in ${page}: ${target}`);
50
+ links++;
51
+ }
52
+ }
53
+ assert.equal(SETTLEMAKER_VERSION, pkg.version);
54
+ assert.equal(Object.keys(SKIN_SLOTS).length, 693);
55
+ const gallery = JSON.parse(read('docs/examples/gallery/fixtures.json'));
56
+ assert.equal(gallery.generatorVersion, pkg.version, 'Regenerate the gallery for this release');
57
+ for (const fixture of gallery.fixtures) {
58
+ assert.equal(fixture.engine, fixture.burg.population <= 1000 ? 'village' : 'settlement');
59
+ for (const ext of ['svg', 'png']) assert.ok(existsSync(join(root, `docs/examples/gallery/${fixture.id}.${ext}`)));
60
+ if (fixture.previewUrl) {
61
+ assert.ok(!fixture.skin, 'The URL codec does not carry skins');
62
+ assert.deepEqual(await decodeBurgParam(new URL(fixture.previewUrl).searchParams.get('i')), { burg: fixture.burg, seed: fixture.seed });
63
+ }
64
+ }
65
+
66
+ const work = mkdtempSync(join(tmpdir(), 'settlemaker-docs-'));
67
+ const entry = pathToFileURL(join(root, 'dist/index.js')).href;
68
+ const blocks = (page, language) => [...read(page).matchAll(new RegExp('```' + language + '\\n([\\s\\S]*?)```', 'g'))].map(m => m[1]);
69
+ const imports = code => code.replaceAll("from 'settlemaker'", `from '${entry}'`).replaceAll("import('settlemaker')", `import('${entry}')`)
70
+ .replaceAll("import.meta.resolve('settlemaker/docs/examples/copperline.skin.json')", JSON.stringify(pathToFileURL(join(root, 'docs/examples/copperline.skin.json')).href));
71
+ function run(name, code) {
72
+ const path = join(work, name + '.mjs');
73
+ writeFileSync(path, imports(code));
74
+ try { execFileSync(process.execPath, [path], { cwd: work, stdio: 'pipe' }); }
75
+ catch (error) { throw new Error(`${name}: ${error.message}\n${error.stderr?.toString().slice(-3000) ?? ''}`); }
76
+ }
77
+ const quick = blocks('README.md', 'js');
78
+ run('readme', quick.join('\n') + '\nif (!result.svg.includes("<svg") || !moonVillage.svg.includes("#68748c")) throw new Error("README output missing");');
79
+ for (const [i, code] of blocks('docs/getting-started.md', 'js').entries()) run(`getting-started-${i}`, code);
80
+ const html = blocks('docs/getting-started.md', 'html')[0];
81
+ const browser = html.match(/<script type="module">([\s\S]*?)<\/script>/)[1]
82
+ .replace("'./assets/settlemaker.browser.js'", JSON.stringify(pathToFileURL(join(root, 'dist/settlemaker.browser.js')).href));
83
+ run('browser-page', 'const element = {}; const document = {querySelector: () => element};\n' + browser + '\nif (!element.innerHTML.includes("<svg")) throw new Error("No browser map");');
84
+ const scene = blocks('docs/scene-schema.md', 'js');
85
+ run('scene', scene[0]);
86
+ const burg = scene[0].match(/const burg = \{[\s\S]*?\n\};/)[0];
87
+ run('lower-level', burg + '\n' + scene[1]);
88
+ run('tiles', quick[0] + '\n' + blocks('docs/geojson.md', 'js')[0] + '\nif (!rootTileSvg.includes("<svg")) throw new Error("No tile");');
89
+ const url = blocks('docs/url-api.md', 'js');
90
+ run('url-encode', url[0]);
91
+ run('url-renderer', 'const location = {search: "?name=Docs&pop=300&plaza=1&seed=2&roads=20,140,270"};\n' + url[1] + '\nif (result.kind !== "village") throw new Error("Wrong URL engine");');
92
+ const typeFiles = [];
93
+ for (const page of ['docs/getting-started.md', 'docs/api.md']) {
94
+ for (const [i, code] of blocks(page, 'ts').entries()) {
95
+ const path = join(work, `${page.replaceAll('/', '-')}-${i}.mts`);
96
+ writeFileSync(path, code.replaceAll("from 'settlemaker'", `from '${join(root, 'dist/index.js')}'`));
97
+ typeFiles.push(path);
98
+ }
99
+ }
100
+ const program = ts.createProgram(typeFiles, { noEmit: true, strict: true, target: ts.ScriptTarget.ES2022,
101
+ module: ts.ModuleKind.NodeNext, moduleResolution: ts.ModuleResolutionKind.NodeNext, types: [] });
102
+ const diagnostics = ts.getPreEmitDiagnostics(program);
103
+ assert.equal(diagnostics.length, 0, ts.formatDiagnosticsWithColorAndContext(diagnostics, {
104
+ getCanonicalFileName: f => f, getCurrentDirectory: () => work, getNewLine: () => '\n',
105
+ }));
106
+ console.log(`Passed: ${pages.length} public pages, ${links} local links/anchors, ${gallery.fixtures.length} gallery fixtures, README/Node/browser/URL/scene/tile examples and TypeScript snippets.`);