fuzzyworld

tower / files / src / world / schema.ts

schema.ts

Blame: the commit that last changed each line · file · history

e5555695 2026-09-07 Pavel Safronov1/**
376f2ce6 2026-09-16 Pavel Safronov2 * The world file, v2. See world-generation.md §3 and design.md §3.2.
3 *
4 * One file holds every feature of the world — landforms, water, forest, settlements,
5 * routes and plain markers — and the terrain is generated from it live, in the
6 * browser (D-062). There is no separate geography file any more.
e5555695 2026-09-07 Pavel Safronov7 *
8 * Coordinates are canon (theta radians, z metres), never map pixels, so the world
9 * survives any change to how the map is drawn (decisions.md D-007, D-011).
10 *
11 * Two text fields, deliberately separate:
12 * blurb — public description, shipped to readers
13 * notes — the author's working notes, NEVER shipped
14 * plus `public: false` for a whole entry that is not ready to exist publicly.
376f2ce6 2026-09-16 Pavel Safronov15 *
9d5a061c 2026-09-27 Pavel Safronov16 * Every kind has the same `geometry` field, so the map, the list and the detail card
376f2ce6 2026-09-16 Pavel Safronov17 * handle all of them one way. What differs by kind is the parameters beside it, and
18 * each phase of world-generation.md §14 adds the parameters its generators use —
19 * nothing is declared here before something reads it.
e5555695 2026-09-07 Pavel Safronov20 */
21
376f2ce6 2026-09-16 Pavel Safronov22export const WORLD_VERSION = 2
23
24// ------------------------------------------------------------------------ kinds
e5555695 2026-09-07 Pavel Safronov25
14d35b7d 2026-09-16 Pavel Safronov26/** Features that shape the ground. A region can too, when it is given an uplift. */
abc0aad0 2026-10-01 Pavel Safronov27export const TERRAIN_KINDS = ['mountains', 'lake', 'sea', 'river', 'desert', 'forest', 'farmland'] as const
376f2ce6 2026-09-16 Pavel Safronov28/** Built things. Generated from in later phases (world-generation.md §4.7, §4.8). */
99a2821b 2026-09-29 Pavel Safronov29export const BUILT_KINDS = ['settlement', 'road', 'path', 'railway'] as const
14d35b7d 2026-09-16 Pavel Safronov30/** Labels on the world. They generate nothing, except a region with an uplift. */
376f2ce6 2026-09-16 Pavel Safronov31export const MARKER_KINDS = ['embassy', 'tower', 'landmark', 'note', 'region'] as const
e5555695 2026-09-07 Pavel Safronov32
376f2ce6 2026-09-16 Pavel Safronov33export const FEATURE_KINDS = [...TERRAIN_KINDS, ...BUILT_KINDS, ...MARKER_KINDS] as const
34export type FeatureKind = (typeof FEATURE_KINDS)[number]
e5555695 2026-09-07 Pavel Safronov35
376f2ce6 2026-09-16 Pavel Safronov36/** Kinds drawn as a filled area: a polygon, or a band that rings the world. */
37export const AREA_KINDS: readonly FeatureKind[] =
38 ['mountains', 'lake', 'sea', 'desert', 'forest', 'region']
39/** Kinds drawn as an open line. */
99a2821b 2026-09-29 Pavel Safronov40export const LINE_KINDS: readonly FeatureKind[] = ['river', 'road', 'path', 'railway']
376f2ce6 2026-09-16 Pavel Safronov41
42export const isAreaKind = (k: FeatureKind): boolean => AREA_KINDS.includes(k)
43export const isLineKind = (k: FeatureKind): boolean => LINE_KINDS.includes(k)
e5555695 2026-09-07 Pavel Safronov44/** Kinds drawn by tracing a path rather than by a single click. */
376f2ce6 2026-09-16 Pavel Safronov45export const isTracedKind = (k: FeatureKind): boolean => isAreaKind(k) || isLineKind(k)
e5555695 2026-09-07 Pavel Safronov46
47export type Geometry =
48 | { type: 'point'; theta: number; z: number }
49 | { type: 'polyline'; points: [number, number][] }
50 | { type: 'polygon'; points: [number, number][] }
04d6779e 2026-09-07 Pavel Safronov51 /**
52 * A slice of the whole circumference between two z. Not expressible as a
53 * polygon: theta 0 and theta 2pi normalise to the same map row, so a band
376f2ce6 2026-09-16 Pavel Safronov54 * written as four corners collapses into a hairline. Tower's seas, desert,
55 * forest and the Girdle all ring the world, so this is a first-class shape (D-027).
04d6779e 2026-09-07 Pavel Safronov56 */
57 | { type: 'band'; zFrom: number; zTo: number }
e5555695 2026-09-07 Pavel Safronov58
376f2ce6 2026-09-16 Pavel Safronov59export type GeometryType = Geometry['type']
60
61// --------------------------------------------------------------------- features
62
63interface Base<K extends FeatureKind> {
e5555695 2026-09-07 Pavel Safronov64 id: string
65 name: string
376f2ce6 2026-09-16 Pavel Safronov66 kind: K
e5555695 2026-09-07 Pavel Safronov67 geometry: Geometry
68 blurb: string
69 notes: string
70 public: boolean
376f2ce6 2026-09-16 Pavel Safronov71 /**
72 * Written once, when the feature is created, and mixed into the world seed.
73 * Never derived from the id: ids follow names until pinned (D-012), and a seed
74 * that followed the id would reshuffle a mountain every time it was renamed.
75 * world-generation.md §5.5.
76 */
77 seed: number
78}
79
80export interface Peak {
81 name: string
82 theta: number
83 z: number
84 elevationM: number
85}
86
87export interface Mountains extends Base<'mountains'> {
88 /**
89 * Summits, each held at its elevation. A range with none is a ridge: a polygon
90 * rises to `heightM` at its middle, and a band to `heightM` along its centre line.
91 */
92 peaks: Peak[]
e7a9f9dd 2026-09-17 Pavel Safronov93 /** How high the generated summits stand: a ring's crest, a massif's middle. */
376f2ce6 2026-09-16 Pavel Safronov94 heightM: number
e7a9f9dd 2026-09-17 Pavel Safronov95 /** Unnamed summits generated per 100 km² of the range, around the authored ones. */
96 generatedPeaks: number
97 /** 0..1: how many spurs the ridges branch into, and how rough the rock is. */
376f2ce6 2026-09-16 Pavel Safronov98 ruggedness: number
e7a9f9dd 2026-09-17 Pavel Safronov99 /** 0..1: how deep water cuts valleys into the range (mountains.ts). */
100 erosion: number
376f2ce6 2026-09-16 Pavel Safronov101 snowlineM: number
102}
103
80bcc97a 2026-09-17 Pavel Safronov104/**
105 * The wind over a body of water. Direction is where it blows *toward*, in degrees from
a088e5d5 2026-09-17 Pavel Safronov106 * deepward toward spinward — the interior view's heading. On a lake or sea it is the
107 * usual wind: what blows whenever Tower's weather plan has nothing else scheduled.
80bcc97a 2026-09-17 Pavel Safronov108 */
109export interface Wind { directionDeg: number; speedMS: number }
110
376f2ce6 2026-09-16 Pavel Safronov111export interface Lake extends Base<'lake'> {
112 /** 'auto' fills the basin from its floor, the way a tarn fills (D-020). */
113 levelM: number | 'auto'
114 depthM: number
115 /** How the floor meets the shore: a steep bowl with a moraine, or a long soft shelf. */
116 basin: 'bowl' | 'flooded'
117 /** Width of the shelving shore, for a flooded basin. */
118 shoreM: number
119 /** Height of the moraine around a bowl. */
120 rimM: number
80bcc97a 2026-09-17 Pavel Safronov121 wind: Wind
376f2ce6 2026-09-16 Pavel Safronov122}
123
124export interface Sea extends Base<'sea'> {
125 levelM: number
126 depthM: number
127 shoreM: number
80bcc97a 2026-09-17 Pavel Safronov128 wind: Wind
a088e5d5 2026-09-17 Pavel Safronov129 /** Sailing ships working the sea. A ring sea's sail round the world (world-generation.md §4.4). */
130 ships: number
e18dad04 2026-09-30 Pavel Safronov131 /** Ice Tower lets stand on it, and the dim it keeps over it, or null for an open sea (D-090). */
132 ice: SeaIce | null
133}
134
135/**
136 * A sea's ice, in bands across the axis (design.md §1.9.6, D-090). Open water up to
137 * `edgeM`, pack ice from there to `fastM`, and fast ice from `fastM` to the end of the sea
138 * beyond it — which end is whichever side of `edgeM` the fast ice is on. Tower dims its
139 * aimed light over the sea toward the ice, and aims none at the fast ice.
140 */
141export interface SeaIce {
142 /** Where the pack ice begins, z in metres: the ice edge. */
143 edgeM: number
144 /** Where the fast ice begins, z in metres. */
145 fastM: number
146 /**
147 * The range Tower raised at the foot of the cap for the ice to stand on (D-093), or null:
148 * rock rising from the sea bed at `fromM` to `heightM` above the datum at the sea's end.
149 */
150 range: { fromM: number; heightM: number } | null
376f2ce6 2026-09-16 Pavel Safronov151}
152
153export interface River extends Base<'river'> {
154 /** Mean flow of this river's own catchment, before any tributary joins it. */
155 flowM3S: number
156 /** Water surface at the source, or 'terrain' to take it from the ground there. */
157 sourceM: number | 'terrain'
158 /** The lake, sea or river it ends in, or 'auto' to find it from where it ends. */
159 outlet: string | 'auto'
160}
161
51c39d8b 2026-09-17 Pavel Safronov162/**
163 * A desert (world-generation.md §4.5). Tower chose every part of this: the dunes run
164 * where Tower sends the wind, and the washes and shorelines are marks of water that never
165 * ran here, quoted from Earth because that is what a desert looks like (§0.1).
166 */
376f2ce6 2026-09-16 Pavel Safronov167export interface Desert extends Base<'desert'> {
168 /** How far the desert's ground stands above the land around it. */
169 upliftM: number
170 /** Width of the slope up onto it. */
171 edgeM: number
51c39d8b 2026-09-17 Pavel Safronov172 dunes: {
173 /** Share of the open ground the dune fields take, 0..1. */
174 coverage: number
175 /** Crest to trough of the big dunes, metres. */
176 heightM: number
177 /** Crest to crest, metres. */
178 spacingM: number
179 /** Which way the wind blows the sand: degrees, 0 deepward, 90 spinward. */
180 directionDeg: number
181 }
182 hills: {
183 coverage: number
184 heightM: number
185 }
186 plateaus: {
187 coverage: number
188 heightM: number
189 /** Cliff bands between flat tops. 1 is a single tier. */
190 terraces: number
191 }
192 /** The marks of water: dry washes, salt pans, and the shorelines of lakes that never were. */
193 pastWater: {
194 /** How dense the wash network is, 0..1; 0 for none. */
195 washes: number
196 /** How many salt pans to lay out, and whether to ring them with abandoned shorelines. */
197 saltPans: number
198 shorelines: boolean
199 }
376f2ce6 2026-09-16 Pavel Safronov200}
201
00ceeedb 2026-09-27 Pavel Safronov202/**
203 * A forest (world-generation.md §4.6). Tower plants these: the density is what Tower
204 * planted, the meadows are where it left the ground open, and a `giants` forest grows
205 * sequoia-class trees near the limit of what a tree can be at 1 g.
206 */
376f2ce6 2026-09-16 Pavel Safronov207export interface Forest extends Base<'forest'> {
208 upliftM: number
209 edgeM: number
00ceeedb 2026-09-27 Pavel Safronov210 /** Mature stems per hectare where the forest is at its fullest. */
211 densityPerHa: number
212 /** 0 all broadleaf, 1 all conifer. */
213 conifer: number
214 /** Share of the forest floor left open as meadows, 0..1. */
215 meadows: number
216 /** The tall wood: 90–115 m giants, far more widely spaced (§4.6). */
217 giants: boolean
218 /** Trees thin out below this elevation and stop above it. */
219 treelineM: number
376f2ce6 2026-09-16 Pavel Safronov220}
221
222export interface Settlement extends Base<'settlement'> {
223 size: 'hamlet' | 'village' | 'town' | 'station' | 'city'
51c39d8b 2026-09-17 Pavel Safronov224 /**
225 * How far the built-up ground reaches from the marker. A settlement is a point with an
226 * extent rather than a traced outline: Tower lays the streets and buildings out inside it
227 * (world-generation.md §4.7), and what the author places is where it stands and how big
228 * it is. The Capital's is 10 km, which is what reaches The Deep's shore from the axial
229 * midpoint of the world (D-070).
230 */
231 radiusM: number
376f2ce6 2026-09-16 Pavel Safronov232}
233
234export interface Route extends Base<'road' | 'path'> {
235 widthM: number
236}
237
99a2821b 2026-09-29 Pavel Safronov238/**
239 * A railway line (D-086). Its track is laid on a grade of its own, over the ground or on a
240 * viaduct above it (terrain/rails.ts), and trains run on it in the scenic tour. A line whose
241 * last point comes back round to its first, a whole circumference on, is a loop.
242 */
243export interface Railway extends Base<'railway'> {
244 /** Line speed, metres a second: what a train runs at along it. */
245 speedMS: number
246}
247
14d35b7d 2026-09-16 Pavel Safronov248export interface Marker extends Base<'embassy' | 'tower' | 'landmark' | 'note'> {
376f2ce6 2026-09-16 Pavel Safronov249 /** Height of a tower, for when towers are drawn. */
250 heightM?: number
251}
252
abc0aad0 2026-10-01 Pavel Safronov253/**
254 * Fields of crops, each lit by Tower on its own day (terrain/farms.ts, D-095). The fields
255 * are laid out from where they are. A farm given as a **point** grows from it over the land
256 * until it has `areaKm2` (D-097); one given as a polygon or band is that outline, with a soft
257 * edge `edgeM` wide.
258 */
259export interface Farmland extends Base<'farmland'> {
260 edgeM: number
261 areaKm2: number
262}
263
14d35b7d 2026-09-16 Pavel Safronov264/**
265 * A named area. It is a label, and it can also be ground Tower raised: `upliftM` lifts
266 * everything inside it, with a soft edge `edgeM` wide, and paints nothing — the land
267 * keeps whatever cover it has. v1's world needed one under its rivers (D-064).
268 */
269export interface Region extends Base<'region'> {
270 upliftM: number
271 edgeM: number
272}
273
376f2ce6 2026-09-16 Pavel Safronov274export type Feature =
abc0aad0 2026-10-01 Pavel Safronov275 | Mountains | Lake | Sea | River | Desert | Forest | Farmland | Settlement | Route | Railway | Marker | Region
376f2ce6 2026-09-16 Pavel Safronov276
277/** The gentle relief everywhere nothing else is (today's "open ground"). */
278export interface Ground {
279 baseM: number
280 /** Amplitude of the broadest swell. The finer layers scale with it. */
281 reliefM: number
282 notes: string
e5555695 2026-09-07 Pavel Safronov283}
284
285export interface World {
286 version: number
376f2ce6 2026-09-16 Pavel Safronov287 /** Changes every feature's randomness at once. Rarely touched. */
288 seed: number
289 ground: Ground
290 features: Feature[]
a088e5d5 2026-09-17 Pavel Safronov291 /** Tower's published schedule of wind (world-generation.md §4.9). */
292 weather: WeatherPlan
59c12827 2026-09-28 Pavel Safronov293 /** Which Earth city Tower keeps its days with (design.md §1.3.4). */
294 calendar: Calendar
e5555695 2026-09-07 Pavel Safronov295}
296
59c12827 2026-09-28 Pavel Safronov297// --------------------------------------------------------------------- calendar
298
299/**
300 * The Earth city whose days Tower keeps (design.md §1.3.4, D-074). Tower's clock is that
301 * city's clock, and its sun comes on at the sun cap at the city's sunrise and goes out at
302 * the far cap at the city's sunset, so Tower's days lengthen and shorten with its seasons.
303 */
304export interface Calendar {
305 city: string
306 /** The city's IANA time zone, for its clock and its daylight saving. */
307 timeZone: string
308 /** Degrees, north positive. */
309 latitude: number
310 /** Degrees, east positive. */
311 longitude: number
312 /** The city's date on day 0, YYYY-MM-DD. */
313 day0: string
314}
315
de3d60d6 2026-09-29 Pavel Safronov316/** Pisa, from the northern autumn equinox of 2026 (author, 2026-09-28; D-089). */
59c12827 2026-09-28 Pavel Safronov317export const defaultCalendar = (): Calendar => ({
de3d60d6 2026-09-29 Pavel Safronov318 city: 'Pisa', timeZone: 'Europe/Rome', latitude: 43.7228, longitude: 10.4017,
59c12827 2026-09-28 Pavel Safronov319 day0: '2026-09-23',
320})
321
a088e5d5 2026-09-17 Pavel Safronov322// ---------------------------------------------------------------------- weather
323
324/**
325 * Tower's weather plan (world-generation.md §4.9). Every wind in Tower is Tower's doing
326 * (design.md §1.9.4); Tower publishes its plans so people can live around them, and
327 * changes them when it chooses. Both halves are kept: what was published, and what Tower
328 * did instead.
329 *
330 * A body of water's own `wind` is its baseline — the wind when nothing is scheduled.
331 * Times are a day number from day 0 and an hour, 0..24 (author, 2026-09-16).
332 */
333export const WEATHER_KINDS = ['calm', 'breeze', 'gale', 'storm'] as const
334export type WeatherKind = (typeof WEATHER_KINDS)[number]
335
336export interface WorldTime { day: number; hour: number }
337
338export interface WeatherRevision {
339 /** When Tower changed its mind. */
340 at: WorldTime
341 kind: WeatherKind
342 wind: Wind
343 reason: string
344}
345
346export interface WeatherEvent {
347 id: string
348 /** The lake or sea it is over. */
349 target: string
350 kind: WeatherKind
351 start: WorldTime
352 durationH: number
353 wind: Wind
354 /** When Tower announced it. Nobody acts on a plan before it is published. */
355 publishedAt: WorldTime
356 revisions: WeatherRevision[]
357}
358
359export interface WeatherPlan {
360 /**
361 * Days after which the plan repeats, or 0 for a plan that happens once. A repeating
362 * plan is a season Tower runs again; a revision belongs to the one occurrence it was
363 * made during.
364 */
365 cycleDays: number
366 events: WeatherEvent[]
367}
368
369export const emptyWeather = (): WeatherPlan => ({ cycleDays: 0, events: [] })
370
376f2ce6 2026-09-16 Pavel Safronov371/** A feature as it reaches readers: no working notes. Unpublished ones never get here. */
372export type PublicFeature = Omit<Feature, 'notes'>
373
374// --------------------------------------------------------------------- defaults
375
376export const defaultGround = (): Ground => ({ baseM: 60, reliefM: 260, notes: '' })
377
378export const emptyWorld = (): World => ({
a088e5d5 2026-09-17 Pavel Safronov379 version: WORLD_VERSION, seed: 1, ground: defaultGround(), features: [], weather: emptyWeather(),
59c12827 2026-09-28 Pavel Safronov380 calendar: defaultCalendar(),
376f2ce6 2026-09-16 Pavel Safronov381})
382
9d5a061c 2026-09-27 Pavel Safronov383/** Which shapes a kind may take, the usual one first. */
376f2ce6 2026-09-16 Pavel Safronov384export function geometryTypesFor(kind: FeatureKind): readonly GeometryType[] {
99a2821b 2026-09-29 Pavel Safronov385 if (kind === 'river' || kind === 'road' || kind === 'path' || kind === 'railway') return ['polyline']
376f2ce6 2026-09-16 Pavel Safronov386 if (kind === 'lake') return ['polygon']
387 if (kind === 'mountains' || kind === 'sea' || kind === 'desert' || kind === 'forest') {
388 return ['polygon', 'band']
389 }
390 if (kind === 'settlement') return ['point']
391 if (kind === 'region') return ['polygon', 'band']
abc0aad0 2026-10-01 Pavel Safronov392 // A farm is grown from a point (D-097), or drawn as an outline.
393 if (kind === 'farmland') return ['point', 'polygon', 'band']
376f2ce6 2026-09-16 Pavel Safronov394 // Markers can be any shape: The Narrows is a landmark with length.
395 return ['point', 'polyline', 'polygon', 'band']
e5555695 2026-09-07 Pavel Safronov396}
397
376f2ce6 2026-09-16 Pavel Safronov398type Common = Pick<Feature, 'id' | 'name' | 'geometry' | 'blurb' | 'notes' | 'public' | 'seed'>
399
400/**
401 * A feature of `kind` with every parameter at its default, keeping the common
402 * fields. Edges default narrow: a region drawn by hand is meant to be about as big as
403 * it was drawn, and its soft edge reaches outward (field.ts). The world's own ringing
404 * belts carry the wide edges v1 gave them.
405 *
9d5a061c 2026-09-27 Pavel Safronov406 * What a new feature starts from, before its own parameters are written in.
376f2ce6 2026-09-16 Pavel Safronov407 */
408export function withKind(common: Common, kind: FeatureKind): Feature {
409 const c = { ...common }
410 switch (kind) {
411 case 'mountains':
e7a9f9dd 2026-09-17 Pavel Safronov412 return {
413 ...c, kind, peaks: [], heightM: 2400, generatedPeaks: 0.6, ruggedness: 0.55, erosion: 0.6,
414 snowlineM: 3000,
415 }
376f2ce6 2026-09-16 Pavel Safronov416 case 'lake':
80bcc97a 2026-09-17 Pavel Safronov417 return { ...c, kind, levelM: 'auto', depthM: 60, basin: 'bowl', shoreM: 2000, rimM: 20,
418 wind: { directionDeg: 60, speedMS: 6 } }
376f2ce6 2026-09-16 Pavel Safronov419 case 'sea':
80bcc97a 2026-09-17 Pavel Safronov420 // A ring sea's wind blows round the world: unlimited fetch (world-generation.md §4.4).
e18dad04 2026-09-30 Pavel Safronov421 return { ...c, kind, levelM: 90, depthM: 420, shoreM: 4000, wind: { directionDeg: 90, speedMS: 9 }, ships: 90,
422 ice: null }
376f2ce6 2026-09-16 Pavel Safronov423 case 'river':
424 return { ...c, kind, flowM3S: 40, sourceM: 'terrain', outlet: 'auto' }
425 case 'desert':
51c39d8b 2026-09-17 Pavel Safronov426 return {
427 ...c, kind, upliftM: 300, edgeM: 4000,
428 dunes: { coverage: 0.45, heightM: 60, spacingM: 900, directionDeg: 90 },
429 hills: { coverage: 0.25, heightM: 120 },
430 plateaus: { coverage: 0.3, heightM: 420, terraces: 3 },
431 pastWater: { washes: 0.6, saltPans: 3, shorelines: true },
432 }
376f2ce6 2026-09-16 Pavel Safronov433 case 'forest':
00ceeedb 2026-09-27 Pavel Safronov434 return {
435 ...c, kind, upliftM: 0, edgeM: 3000, densityPerHa: 450, conifer: 0.55,
436 meadows: 0.12, giants: false, treelineM: 2200,
437 }
abc0aad0 2026-10-01 Pavel Safronov438 case 'farmland':
439 return { ...c, kind, edgeM: 600, areaKm2: 28 }
376f2ce6 2026-09-16 Pavel Safronov440 case 'settlement':
51c39d8b 2026-09-17 Pavel Safronov441 return { ...c, kind, size: 'village', radiusM: SETTLEMENT_RADIUS_M.village }
376f2ce6 2026-09-16 Pavel Safronov442 case 'road':
443 case 'path':
444 return { ...c, kind, widthM: kind === 'road' ? 8 : 2 }
99a2821b 2026-09-29 Pavel Safronov445 case 'railway':
446 return { ...c, kind, speedMS: 70 }
14d35b7d 2026-09-16 Pavel Safronov447 case 'region':
448 return { ...c, kind, upliftM: 0, edgeM: 4000 }
376f2ce6 2026-09-16 Pavel Safronov449 default:
450 return { ...c, kind }
451 }
452}
453
454// -------------------------------------------------------------------------- ids
e5555695 2026-09-07 Pavel Safronov455
9d5a061c 2026-09-27 Pavel Safronov456/** An id from a name, unique among `taken`. Also fills in a file's missing ids. */
e5555695 2026-09-07 Pavel Safronov457export function makeId(name: string, taken: ReadonlySet<string>): string {
458 const base =
459 name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'place'
460 if (!taken.has(base)) return base
461 for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`
462}
463
376f2ce6 2026-09-16 Pavel Safronov464/** A single representative point for a feature, whatever shape it is. */
51c39d8b 2026-09-17 Pavel Safronov465/** How far the usual settlement of each size reaches from its marker, metres. */
466export const SETTLEMENT_RADIUS_M: Record<Settlement['size'], number> = {
467 hamlet: 250, village: 600, station: 500, town: 1800, city: 5000,
468}
469
376f2ce6 2026-09-16 Pavel Safronov470export function anchorOf(f: Feature): { theta: number; z: number } {
471 if (f.kind === 'mountains' && f.peaks[0]) return { theta: f.peaks[0].theta, z: f.peaks[0].z }
472 const g = f.geometry
473 if (g.type === 'point') return { theta: g.theta, z: g.z }
474 if (g.type === 'band') return { theta: 0, z: (g.zFrom + g.zTo) / 2 }
475 return { theta: g.points[0]![0], z: g.points[0]![1] }
476}
477
478// ---------------------------------------------------------------------- parsing
479
480const num = (v: unknown, fallback: number): number =>
481 typeof v === 'number' && Number.isFinite(v) ? v : fallback
482const unit = (v: unknown, fallback: number): number => Math.min(1, Math.max(0, num(v, fallback)))
483const str = (v: unknown, fallback = ''): string => (typeof v === 'string' ? v : fallback)
484
e5555695 2026-09-07 Pavel Safronov485/**
486 * Accept anything shaped like a world, discarding entries that are not. Import
376f2ce6 2026-09-16 Pavel Safronov487 * handles files written by hand, so it must not throw. A v1 file has no
488 * `features` and comes back empty: there is no backwards compatibility, and the
489 * one v1 world that mattered was ported by tools/port-world.mjs.
e5555695 2026-09-07 Pavel Safronov490 */
491export function parseWorld(raw: unknown): World {
492 const src = raw as Partial<World> | null
376f2ce6 2026-09-16 Pavel Safronov493 const world = emptyWorld()
494 if (!src || typeof src !== 'object') return world
495 world.seed = Math.floor(num(src.seed, 1))
496 const g = src.ground as Partial<Ground> | undefined
497 if (g && typeof g === 'object') {
498 world.ground = {
499 baseM: num(g.baseM, 60), reliefM: Math.max(0, num(g.reliefM, 260)), notes: str(g.notes),
e5555695 2026-09-07 Pavel Safronov500 }
501 }
a088e5d5 2026-09-17 Pavel Safronov502 world.weather = parseWeather(src.weather)
59c12827 2026-09-28 Pavel Safronov503 world.calendar = parseCalendar(src.calendar)
376f2ce6 2026-09-16 Pavel Safronov504 if (!Array.isArray(src.features)) return world
505 const seen = new Set<string>()
506 for (const f of src.features as unknown as Record<string, unknown>[]) {
507 const parsed = parseFeature(f, seen)
508 if (!parsed) continue
509 seen.add(parsed.id)
510 world.features.push(parsed)
511 }
512 return world
513}
514
de3d60d6 2026-09-29 Pavel Safronov515/** A calendar with anything unusable in it falls back to Pisa field by field. */
59c12827 2026-09-28 Pavel Safronov516function parseCalendar(raw: unknown): Calendar {
517 const d = defaultCalendar()
518 if (!raw || typeof raw !== 'object') return d
519 const c = raw as Record<string, unknown>
520 const zone = typeof c['timeZone'] === 'string' && validTimeZone(c['timeZone']) ? c['timeZone'] : d.timeZone
521 const day0 = typeof c['day0'] === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(c['day0'])
522 && !Number.isNaN(Date.parse(`${c['day0']}T00:00:00Z`)) ? c['day0'] : d.day0
523 return {
524 city: str(c['city']).trim() || d.city,
525 timeZone: zone,
526 latitude: Math.max(-66, Math.min(66, num(c['latitude'], d.latitude))),
527 longitude: wrapLongitude(num(c['longitude'], d.longitude)),
528 day0,
529 }
530}
531
532/** Into -180..180, leaving a longitude already there exactly as it was written. */
533function wrapLongitude(lon: number): number {
534 return lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180
535}
536
537function validTimeZone(zone: string): boolean {
538 try {
539 new Intl.DateTimeFormat('en', { timeZone: zone })
540 return true
541 } catch {
542 return false
543 }
544}
545
376f2ce6 2026-09-16 Pavel Safronov546function parseFeature(f: Record<string, unknown> | null, seen: Set<string>): Feature | null {
547 if (!f || typeof f !== 'object') return null
548 const geometry = parseGeometry(f['geometry'])
549 if (!geometry) return null
550 const kind: FeatureKind = FEATURE_KINDS.includes(f['kind'] as FeatureKind)
551 ? (f['kind'] as FeatureKind)
552 : 'landmark'
553 // A shape the kind cannot take would give the generators nothing to work with.
554 if (!geometryTypesFor(kind).includes(geometry.type)) return null
555 const name = str(f['name']).trim() ? str(f['name']) : 'Unnamed'
556 const rawId = f['id']
557 const id = typeof rawId === 'string' && rawId && !seen.has(rawId) ? rawId : makeId(name, seen)
558 const base = withKind({
559 id, name, geometry,
560 blurb: str(f['blurb']),
561 notes: str(f['notes']),
562 public: f['public'] !== false,
563 seed: Math.floor(num(f['seed'], hashString(id))),
564 }, kind)
565 return fillParams(base, f)
566}
567
568/** Overlay whatever valid parameters the source carries onto a kind's defaults. */
569function fillParams(d: Feature, f: Record<string, unknown>): Feature {
570 switch (d.kind) {
571 case 'mountains': {
572 const peaks = Array.isArray(f['peaks'])
573 ? (f['peaks'] as Record<string, unknown>[]).flatMap((p): Peak[] =>
574 p && Number.isFinite(p['theta']) && Number.isFinite(p['z']) && Number.isFinite(p['elevationM'])
575 ? [{ name: str(p['name']), theta: p['theta'] as number, z: p['z'] as number,
576 elevationM: p['elevationM'] as number }]
577 : [])
578 : []
579 return { ...d, peaks, heightM: num(f['heightM'], d.heightM),
e7a9f9dd 2026-09-17 Pavel Safronov580 generatedPeaks: Math.max(0, num(f['generatedPeaks'], d.generatedPeaks)),
376f2ce6 2026-09-16 Pavel Safronov581 ruggedness: unit(f['ruggedness'], d.ruggedness),
e7a9f9dd 2026-09-17 Pavel Safronov582 erosion: unit(f['erosion'], d.erosion),
376f2ce6 2026-09-16 Pavel Safronov583 snowlineM: num(f['snowlineM'], d.snowlineM) }
584 }
585 case 'lake':
586 return { ...d,
587 levelM: Number.isFinite(f['levelM']) ? (f['levelM'] as number) : 'auto',
588 depthM: Math.max(1, num(f['depthM'], d.depthM)),
589 basin: f['basin'] === 'flooded' ? 'flooded' : 'bowl',
590 shoreM: Math.max(1, num(f['shoreM'], d.shoreM)),
80bcc97a 2026-09-17 Pavel Safronov591 rimM: Math.max(0, num(f['rimM'], d.rimM)),
592 wind: parseWind(f['wind'], d.wind) }
376f2ce6 2026-09-16 Pavel Safronov593 case 'sea':
594 return { ...d, levelM: num(f['levelM'], d.levelM),
595 depthM: Math.max(1, num(f['depthM'], d.depthM)),
80bcc97a 2026-09-17 Pavel Safronov596 shoreM: Math.max(1, num(f['shoreM'], d.shoreM)),
a088e5d5 2026-09-17 Pavel Safronov597 wind: parseWind(f['wind'], d.wind),
e18dad04 2026-09-30 Pavel Safronov598 ships: Math.max(0, Math.min(400, Math.floor(num(f['ships'], d.ships)))),
599 ice: parseIce(f['ice']) }
376f2ce6 2026-09-16 Pavel Safronov600 case 'river':
601 return { ...d, flowM3S: Math.max(0.1, num(f['flowM3S'], d.flowM3S)),
602 sourceM: Number.isFinite(f['sourceM']) ? (f['sourceM'] as number) : 'terrain',
603 outlet: typeof f['outlet'] === 'string' && f['outlet'] ? f['outlet'] : 'auto' }
51c39d8b 2026-09-17 Pavel Safronov604 case 'desert': {
605 const group = <T extends Record<string, number | boolean>>(src: unknown, def: T): T => {
606 const o = (src && typeof src === 'object' ? src : {}) as Record<string, unknown>
607 const out = { ...def } as Record<string, number | boolean>
608 for (const [k, v] of Object.entries(def)) {
609 out[k] = typeof v === 'boolean' ? (typeof o[k] === 'boolean' ? o[k] : v) : num(o[k], v)
610 }
611 return out as T
612 }
613 const dunes = group(f['dunes'], d.dunes)
614 const plateaus = group(f['plateaus'], d.plateaus)
615 return {
616 ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)),
617 dunes: {
618 ...dunes,
619 coverage: unit(dunes.coverage, d.dunes.coverage),
620 heightM: Math.max(0, dunes.heightM),
621 spacingM: Math.max(50, dunes.spacingM),
622 directionDeg: ((dunes.directionDeg % 360) + 360) % 360,
623 },
624 hills: {
625 coverage: unit(group(f['hills'], d.hills).coverage, d.hills.coverage),
626 heightM: Math.max(0, group(f['hills'], d.hills).heightM),
627 },
628 plateaus: {
629 coverage: unit(plateaus.coverage, d.plateaus.coverage),
630 heightM: Math.max(0, plateaus.heightM),
631 terraces: Math.max(1, Math.min(8, Math.round(plateaus.terraces))),
632 },
633 pastWater: {
634 washes: unit(group(f['pastWater'], d.pastWater).washes, d.pastWater.washes),
635 saltPans: Math.max(0, Math.min(12, Math.round(group(f['pastWater'], d.pastWater).saltPans))),
636 shorelines: group(f['pastWater'], d.pastWater).shorelines,
637 },
638 }
639 }
376f2ce6 2026-09-16 Pavel Safronov640 case 'forest':
00ceeedb 2026-09-27 Pavel Safronov641 return {
642 ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)),
643 densityPerHa: Math.max(1, Math.min(2000, num(f['densityPerHa'], d.densityPerHa))),
644 conifer: unit(f['conifer'], d.conifer),
645 meadows: unit(f['meadows'], d.meadows),
646 giants: typeof f['giants'] === 'boolean' ? f['giants'] : d.giants,
647 treelineM: num(f['treelineM'], d.treelineM),
648 }
14d35b7d 2026-09-16 Pavel Safronov649 case 'region':
376f2ce6 2026-09-16 Pavel Safronov650 return { ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)) }
abc0aad0 2026-10-01 Pavel Safronov651 case 'farmland':
652 return { ...d, edgeM: Math.max(1, num(f['edgeM'], d.edgeM)),
653 areaKm2: Math.max(0.1, Math.min(2000, num(f['areaKm2'], d.areaKm2))) }
376f2ce6 2026-09-16 Pavel Safronov654 case 'settlement': {
655 const sizes = ['hamlet', 'village', 'town', 'station', 'city'] as const
656 const size = sizes.includes(f['size'] as never) ? (f['size'] as Settlement['size']) : d.size
51c39d8b 2026-09-17 Pavel Safronov657 // A settlement that names no extent gets the usual one for its size.
658 const radiusM = Math.max(50, Math.min(40_000, num(f['radiusM'], SETTLEMENT_RADIUS_M[size])))
659 return { ...d, size, radiusM }
376f2ce6 2026-09-16 Pavel Safronov660 }
661 case 'road':
662 case 'path':
663 return { ...d, widthM: Math.max(0.5, num(f['widthM'], d.widthM)) }
99a2821b 2026-09-29 Pavel Safronov664 case 'railway':
665 return { ...d, speedMS: Math.max(5, Math.min(200, num(f['speedMS'], d.speedMS))) }
376f2ce6 2026-09-16 Pavel Safronov666 default:
667 return Number.isFinite(f['heightM']) ? { ...d, heightM: f['heightM'] as number } : d
668 }
e5555695 2026-09-07 Pavel Safronov669}
670
a088e5d5 2026-09-17 Pavel Safronov671function parseTime(t: unknown): WorldTime | null {
672 const src = t as Partial<WorldTime> | null
673 if (!src || !Number.isFinite(src.day) || !Number.isFinite(src.hour)) return null
674 return { day: Math.max(0, Math.floor(src.day!)), hour: Math.min(24, Math.max(0, src.hour!)) }
675}
676
677function parseWeather(w: unknown): WeatherPlan {
678 const src = w as Partial<WeatherPlan> | null
679 const plan = emptyWeather()
680 if (!src || typeof src !== 'object') return plan
681 plan.cycleDays = Math.max(0, Math.floor(num(src.cycleDays, 0)))
682 if (!Array.isArray(src.events)) return plan
683 const seen = new Set<string>()
684 for (const e of src.events as unknown as Record<string, unknown>[]) {
685 if (!e || typeof e !== 'object') continue
686 const start = parseTime(e['start'])
687 const target = str(e['target'])
688 if (!start || !target) continue
689 const kind: WeatherKind = WEATHER_KINDS.includes(e['kind'] as WeatherKind) ? (e['kind'] as WeatherKind) : 'breeze'
690 const id = typeof e['id'] === 'string' && e['id'] && !seen.has(e['id']) ? e['id'] : makeId(`${target}-${kind}`, seen)
691 seen.add(id)
692 const revisions = Array.isArray(e['revisions'])
693 ? (e['revisions'] as Record<string, unknown>[]).flatMap((r): WeatherRevision[] => {
694 const at = parseTime(r?.['at'])
695 if (!at) return []
696 const rk = WEATHER_KINDS.includes(r['kind'] as WeatherKind) ? (r['kind'] as WeatherKind) : kind
697 return [{ at, kind: rk, wind: parseWind(r['wind'], { directionDeg: 0, speedMS: 0 }), reason: str(r['reason']) }]
698 })
699 : []
700 plan.events.push({
701 id, target, kind, start,
702 durationH: Math.max(0.5, num(e['durationH'], 6)),
703 wind: parseWind(e['wind'], { directionDeg: 90, speedMS: 10 }),
704 publishedAt: parseTime(e['publishedAt']) ?? start,
705 revisions,
706 })
707 }
708 return plan
709}
710
80bcc97a 2026-09-17 Pavel Safronov711function parseWind(w: unknown, fallback: Wind): Wind {
712 const src = w as Partial<Wind> | null
713 if (!src || typeof src !== 'object') return fallback
714 return {
715 directionDeg: num(src.directionDeg, fallback.directionDeg),
716 speedMS: Math.min(60, Math.max(0, num(src.speedMS, fallback.speedMS))),
717 }
718}
719
e18dad04 2026-09-30 Pavel Safronov720/** A sea's ice, or null where it has none or the two edges are not two different places. */
721function parseIce(i: unknown): SeaIce | null {
722 const src = i as Partial<SeaIce> | null
723 if (!src || typeof src !== 'object') return null
724 if (!Number.isFinite(src.edgeM) || !Number.isFinite(src.fastM) || src.edgeM === src.fastM) return null
725 const r = src.range as Partial<{ fromM: number; heightM: number }> | null | undefined
726 const range = r && Number.isFinite(r.fromM) && Number.isFinite(r.heightM) && r.heightM! > 0
727 ? { fromM: r.fromM!, heightM: r.heightM! } : null
728 return { edgeM: src.edgeM!, fastM: src.fastM!, range }
729}
730
e5555695 2026-09-07 Pavel Safronov731function parseGeometry(g: unknown): Geometry | null {
04d6779e 2026-09-07 Pavel Safronov732 const src = g as {
733 type?: string; theta?: number; z?: number; points?: unknown
734 zFrom?: number; zTo?: number
735 }
e5555695 2026-09-07 Pavel Safronov736 if (!src) return null
737 if (src.type === 'point' && Number.isFinite(src.theta) && Number.isFinite(src.z)) {
738 return { type: 'point', theta: src.theta!, z: src.z! }
739 }
04d6779e 2026-09-07 Pavel Safronov740 if (src.type === 'band' && Number.isFinite(src.zFrom) && Number.isFinite(src.zTo)) {
376f2ce6 2026-09-16 Pavel Safronov741 return { type: 'band', zFrom: Math.min(src.zFrom!, src.zTo!), zTo: Math.max(src.zFrom!, src.zTo!) }
04d6779e 2026-09-07 Pavel Safronov742 }
e5555695 2026-09-07 Pavel Safronov743 if ((src.type === 'polygon' || src.type === 'polyline') && Array.isArray(src.points)) {
744 const points = src.points
745 .filter((pt): pt is [number, number] =>
746 Array.isArray(pt) && pt.length === 2 && pt.every(Number.isFinite))
747 .map(([t, z]): [number, number] => [t, z])
748 // A polygon needs an interior; a polyline only needs to go somewhere.
749 const min = src.type === 'polygon' ? 3 : 2
750 if (points.length >= min) return { type: src.type, points }
751 }
752 return null
753}
754
376f2ce6 2026-09-16 Pavel Safronov755/** A stable seed for a file that did not carry one, so it at least stays put. */
756export function hashString(s: string): number {
757 let h = 2166136261
758 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619)
759 return (h >>> 0) & 0x7fffffff
760}
761
e5555695 2026-09-07 Pavel Safronov762/**
376f2ce6 2026-09-16 Pavel Safronov763 * Strip a world down to what readers get. Drops non-public features entirely and
764 * removes every `notes` field, the ground's included — see decisions.md D-011 on
765 * why this is not, by itself, sufficient privacy.
766 *
767 * A dropped feature takes its terrain with it: readers get no basin for an
768 * unpublished lake. That is the point — a hole in the ground shaped like a lake
769 * would publish the lake (world-generation.md §3.3).
e5555695 2026-09-07 Pavel Safronov770 */
376f2ce6 2026-09-16 Pavel Safronov771export function toPublicWorld(world: World): World {
e5555695 2026-09-07 Pavel Safronov772 return {
773 version: WORLD_VERSION,
376f2ce6 2026-09-16 Pavel Safronov774 seed: world.seed,
a088e5d5 2026-09-17 Pavel Safronov775 // Tower publishes its plans: the weather is public, revisions and their reasons too.
776 weather: world.weather,
59c12827 2026-09-28 Pavel Safronov777 calendar: world.calendar,
376f2ce6 2026-09-16 Pavel Safronov778 ground: { ...world.ground, notes: '' },
779 features: world.features
780 .filter((f) => f.public)
781 .map((f) => ({ ...f, notes: '' })),
e5555695 2026-09-07 Pavel Safronov782 }
783}
376f2ce6 2026-09-16 Pavel Safronov784
785/**
786 * Everything about the world that changes its terrain, and nothing that does not.
787 * Renaming a mountain or writing a blurb must not regenerate the world.
788 */
789export function terrainSignature(world: World): string {
790 return JSON.stringify({
791 seed: world.seed,
792 ground: { baseM: world.ground.baseM, reliefM: world.ground.reliefM },
793 // The id stays out too: a rename re-ids a feature, and references to it (a
a088e5d5 2026-09-17 Pavel Safronov794 // river's outlet) are rewritten with it, which is what shows up here instead. Wind
795 // and ships change nothing on the ground either: they are read live (weather.ts).
796 features: world.features.map((f) => {
797 const { id: _i, name: _n, blurb: _b, notes: _no, public: _p, ...rest } = f as Feature & { wind?: unknown; ships?: unknown }
798 const { wind: _w, ships: _s, ...ground } = rest as typeof rest & { wind?: unknown; ships?: unknown }
799 return ground
800 }),
376f2ce6 2026-09-16 Pavel Safronov801 })
802}