tower / files / src / world / schema.ts
schema.ts
| 1 | /** |
| 2 | * 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. |
| 7 | * |
| 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. |
| 15 | * |
| 16 | * Every kind has the same `geometry` field, so the map, the list and the detail card |
| 17 | * 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. |
| 20 | */ |
| 21 | |
| 22 | export const WORLD_VERSION = 2 |
| 23 | |
| 24 | // ------------------------------------------------------------------------ kinds |
| 25 | |
| 26 | /** Features that shape the ground. A region can too, when it is given an uplift. */ |
| 27 | export const TERRAIN_KINDS = ['mountains', 'lake', 'sea', 'river', 'desert', 'forest', 'farmland'] as const |
| 28 | /** Built things. Generated from in later phases (world-generation.md §4.7, §4.8). */ |
| 29 | export const BUILT_KINDS = ['settlement', 'road', 'path', 'railway'] as const |
| 30 | /** Labels on the world. They generate nothing, except a region with an uplift. */ |
| 31 | export const MARKER_KINDS = ['embassy', 'tower', 'landmark', 'note', 'region'] as const |
| 32 | |
| 33 | export const FEATURE_KINDS = [...TERRAIN_KINDS, ...BUILT_KINDS, ...MARKER_KINDS] as const |
| 34 | export type FeatureKind = (typeof FEATURE_KINDS)[number] |
| 35 | |
| 36 | /** Kinds drawn as a filled area: a polygon, or a band that rings the world. */ |
| 37 | export const AREA_KINDS: readonly FeatureKind[] = |
| 38 | ['mountains', 'lake', 'sea', 'desert', 'forest', 'region'] |
| 39 | /** Kinds drawn as an open line. */ |
| 40 | export const LINE_KINDS: readonly FeatureKind[] = ['river', 'road', 'path', 'railway'] |
| 41 | |
| 42 | export const isAreaKind = (k: FeatureKind): boolean => AREA_KINDS.includes(k) |
| 43 | export const isLineKind = (k: FeatureKind): boolean => LINE_KINDS.includes(k) |
| 44 | /** Kinds drawn by tracing a path rather than by a single click. */ |
| 45 | export const isTracedKind = (k: FeatureKind): boolean => isAreaKind(k) || isLineKind(k) |
| 46 | |
| 47 | export type Geometry = |
| 48 | | { type: 'point'; theta: number; z: number } |
| 49 | | { type: 'polyline'; points: [number, number][] } |
| 50 | | { type: 'polygon'; points: [number, number][] } |
| 51 | /** |
| 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 |
| 54 | * 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). |
| 56 | */ |
| 57 | | { type: 'band'; zFrom: number; zTo: number } |
| 58 | |
| 59 | export type GeometryType = Geometry['type'] |
| 60 | |
| 61 | // --------------------------------------------------------------------- features |
| 62 | |
| 63 | interface Base<K extends FeatureKind> { |
| 64 | id: string |
| 65 | name: string |
| 66 | kind: K |
| 67 | geometry: Geometry |
| 68 | blurb: string |
| 69 | notes: string |
| 70 | public: boolean |
| 71 | /** |
| 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 | |
| 80 | export interface Peak { |
| 81 | name: string |
| 82 | theta: number |
| 83 | z: number |
| 84 | elevationM: number |
| 85 | } |
| 86 | |
| 87 | export 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[] |
| 93 | /** How high the generated summits stand: a ring's crest, a massif's middle. */ |
| 94 | heightM: number |
| 95 | /** 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. */ |
| 98 | ruggedness: number |
| 99 | /** 0..1: how deep water cuts valleys into the range (mountains.ts). */ |
| 100 | erosion: number |
| 101 | snowlineM: number |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * The wind over a body of water. Direction is where it blows *toward*, in degrees from |
| 106 | * 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. |
| 108 | */ |
| 109 | export interface Wind { directionDeg: number; speedMS: number } |
| 110 | |
| 111 | export 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 |
| 121 | wind: Wind |
| 122 | } |
| 123 | |
| 124 | export interface Sea extends Base<'sea'> { |
| 125 | levelM: number |
| 126 | depthM: number |
| 127 | shoreM: number |
| 128 | wind: Wind |
| 129 | /** Sailing ships working the sea. A ring sea's sail round the world (world-generation.md §4.4). */ |
| 130 | ships: number |
| 131 | /** 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 | */ |
| 141 | export 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 |
| 151 | } |
| 152 | |
| 153 | export 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 | |
| 162 | /** |
| 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 | */ |
| 167 | export 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 |
| 172 | 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 | } |
| 200 | } |
| 201 | |
| 202 | /** |
| 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 | */ |
| 207 | export interface Forest extends Base<'forest'> { |
| 208 | upliftM: number |
| 209 | edgeM: number |
| 210 | /** 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 |
| 220 | } |
| 221 | |
| 222 | export interface Settlement extends Base<'settlement'> { |
| 223 | size: 'hamlet' | 'village' | 'town' | 'station' | 'city' |
| 224 | /** |
| 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 |
| 232 | } |
| 233 | |
| 234 | export interface Route extends Base<'road' | 'path'> { |
| 235 | widthM: number |
| 236 | } |
| 237 | |
| 238 | /** |
| 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 | */ |
| 243 | export interface Railway extends Base<'railway'> { |
| 244 | /** Line speed, metres a second: what a train runs at along it. */ |
| 245 | speedMS: number |
| 246 | } |
| 247 | |
| 248 | export interface Marker extends Base<'embassy' | 'tower' | 'landmark' | 'note'> { |
| 249 | /** Height of a tower, for when towers are drawn. */ |
| 250 | heightM?: number |
| 251 | } |
| 252 | |
| 253 | /** |
| 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 | */ |
| 259 | export interface Farmland extends Base<'farmland'> { |
| 260 | edgeM: number |
| 261 | areaKm2: number |
| 262 | } |
| 263 | |
| 264 | /** |
| 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 | */ |
| 269 | export interface Region extends Base<'region'> { |
| 270 | upliftM: number |
| 271 | edgeM: number |
| 272 | } |
| 273 | |
| 274 | export type Feature = |
| 275 | | Mountains | Lake | Sea | River | Desert | Forest | Farmland | Settlement | Route | Railway | Marker | Region |
| 276 | |
| 277 | /** The gentle relief everywhere nothing else is (today's "open ground"). */ |
| 278 | export interface Ground { |
| 279 | baseM: number |
| 280 | /** Amplitude of the broadest swell. The finer layers scale with it. */ |
| 281 | reliefM: number |
| 282 | notes: string |
| 283 | } |
| 284 | |
| 285 | export interface World { |
| 286 | version: number |
| 287 | /** Changes every feature's randomness at once. Rarely touched. */ |
| 288 | seed: number |
| 289 | ground: Ground |
| 290 | features: Feature[] |
| 291 | /** Tower's published schedule of wind (world-generation.md §4.9). */ |
| 292 | weather: WeatherPlan |
| 293 | /** Which Earth city Tower keeps its days with (design.md §1.3.4). */ |
| 294 | calendar: Calendar |
| 295 | } |
| 296 | |
| 297 | // --------------------------------------------------------------------- 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 | */ |
| 304 | export 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 | |
| 316 | /** Pisa, from the northern autumn equinox of 2026 (author, 2026-09-28; D-089). */ |
| 317 | export const defaultCalendar = (): Calendar => ({ |
| 318 | city: 'Pisa', timeZone: 'Europe/Rome', latitude: 43.7228, longitude: 10.4017, |
| 319 | day0: '2026-09-23', |
| 320 | }) |
| 321 | |
| 322 | // ---------------------------------------------------------------------- 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 | */ |
| 333 | export const WEATHER_KINDS = ['calm', 'breeze', 'gale', 'storm'] as const |
| 334 | export type WeatherKind = (typeof WEATHER_KINDS)[number] |
| 335 | |
| 336 | export interface WorldTime { day: number; hour: number } |
| 337 | |
| 338 | export interface WeatherRevision { |
| 339 | /** When Tower changed its mind. */ |
| 340 | at: WorldTime |
| 341 | kind: WeatherKind |
| 342 | wind: Wind |
| 343 | reason: string |
| 344 | } |
| 345 | |
| 346 | export 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 | |
| 359 | export 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 | |
| 369 | export const emptyWeather = (): WeatherPlan => ({ cycleDays: 0, events: [] }) |
| 370 | |
| 371 | /** A feature as it reaches readers: no working notes. Unpublished ones never get here. */ |
| 372 | export type PublicFeature = Omit<Feature, 'notes'> |
| 373 | |
| 374 | // --------------------------------------------------------------------- defaults |
| 375 | |
| 376 | export const defaultGround = (): Ground => ({ baseM: 60, reliefM: 260, notes: '' }) |
| 377 | |
| 378 | export const emptyWorld = (): World => ({ |
| 379 | version: WORLD_VERSION, seed: 1, ground: defaultGround(), features: [], weather: emptyWeather(), |
| 380 | calendar: defaultCalendar(), |
| 381 | }) |
| 382 | |
| 383 | /** Which shapes a kind may take, the usual one first. */ |
| 384 | export function geometryTypesFor(kind: FeatureKind): readonly GeometryType[] { |
| 385 | if (kind === 'river' || kind === 'road' || kind === 'path' || kind === 'railway') return ['polyline'] |
| 386 | 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'] |
| 392 | // A farm is grown from a point (D-097), or drawn as an outline. |
| 393 | if (kind === 'farmland') return ['point', 'polygon', 'band'] |
| 394 | // Markers can be any shape: The Narrows is a landmark with length. |
| 395 | return ['point', 'polyline', 'polygon', 'band'] |
| 396 | } |
| 397 | |
| 398 | type 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 | * |
| 406 | * What a new feature starts from, before its own parameters are written in. |
| 407 | */ |
| 408 | export function withKind(common: Common, kind: FeatureKind): Feature { |
| 409 | const c = { ...common } |
| 410 | switch (kind) { |
| 411 | case 'mountains': |
| 412 | return { |
| 413 | ...c, kind, peaks: [], heightM: 2400, generatedPeaks: 0.6, ruggedness: 0.55, erosion: 0.6, |
| 414 | snowlineM: 3000, |
| 415 | } |
| 416 | case 'lake': |
| 417 | return { ...c, kind, levelM: 'auto', depthM: 60, basin: 'bowl', shoreM: 2000, rimM: 20, |
| 418 | wind: { directionDeg: 60, speedMS: 6 } } |
| 419 | case 'sea': |
| 420 | // A ring sea's wind blows round the world: unlimited fetch (world-generation.md §4.4). |
| 421 | return { ...c, kind, levelM: 90, depthM: 420, shoreM: 4000, wind: { directionDeg: 90, speedMS: 9 }, ships: 90, |
| 422 | ice: null } |
| 423 | case 'river': |
| 424 | return { ...c, kind, flowM3S: 40, sourceM: 'terrain', outlet: 'auto' } |
| 425 | case 'desert': |
| 426 | 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 | } |
| 433 | case 'forest': |
| 434 | return { |
| 435 | ...c, kind, upliftM: 0, edgeM: 3000, densityPerHa: 450, conifer: 0.55, |
| 436 | meadows: 0.12, giants: false, treelineM: 2200, |
| 437 | } |
| 438 | case 'farmland': |
| 439 | return { ...c, kind, edgeM: 600, areaKm2: 28 } |
| 440 | case 'settlement': |
| 441 | return { ...c, kind, size: 'village', radiusM: SETTLEMENT_RADIUS_M.village } |
| 442 | case 'road': |
| 443 | case 'path': |
| 444 | return { ...c, kind, widthM: kind === 'road' ? 8 : 2 } |
| 445 | case 'railway': |
| 446 | return { ...c, kind, speedMS: 70 } |
| 447 | case 'region': |
| 448 | return { ...c, kind, upliftM: 0, edgeM: 4000 } |
| 449 | default: |
| 450 | return { ...c, kind } |
| 451 | } |
| 452 | } |
| 453 | |
| 454 | // -------------------------------------------------------------------------- ids |
| 455 | |
| 456 | /** An id from a name, unique among `taken`. Also fills in a file's missing ids. */ |
| 457 | export 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 | |
| 464 | /** A single representative point for a feature, whatever shape it is. */ |
| 465 | /** How far the usual settlement of each size reaches from its marker, metres. */ |
| 466 | export const SETTLEMENT_RADIUS_M: Record<Settlement['size'], number> = { |
| 467 | hamlet: 250, village: 600, station: 500, town: 1800, city: 5000, |
| 468 | } |
| 469 | |
| 470 | export 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 | |
| 480 | const num = (v: unknown, fallback: number): number => |
| 481 | typeof v === 'number' && Number.isFinite(v) ? v : fallback |
| 482 | const unit = (v: unknown, fallback: number): number => Math.min(1, Math.max(0, num(v, fallback))) |
| 483 | const str = (v: unknown, fallback = ''): string => (typeof v === 'string' ? v : fallback) |
| 484 | |
| 485 | /** |
| 486 | * Accept anything shaped like a world, discarding entries that are not. Import |
| 487 | * 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. |
| 490 | */ |
| 491 | export function parseWorld(raw: unknown): World { |
| 492 | const src = raw as Partial<World> | null |
| 493 | 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), |
| 500 | } |
| 501 | } |
| 502 | world.weather = parseWeather(src.weather) |
| 503 | world.calendar = parseCalendar(src.calendar) |
| 504 | 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 | |
| 515 | /** A calendar with anything unusable in it falls back to Pisa field by field. */ |
| 516 | function 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. */ |
| 533 | function wrapLongitude(lon: number): number { |
| 534 | return lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180 |
| 535 | } |
| 536 | |
| 537 | function validTimeZone(zone: string): boolean { |
| 538 | try { |
| 539 | new Intl.DateTimeFormat('en', { timeZone: zone }) |
| 540 | return true |
| 541 | } catch { |
| 542 | return false |
| 543 | } |
| 544 | } |
| 545 | |
| 546 | function 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. */ |
| 569 | function 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), |
| 580 | generatedPeaks: Math.max(0, num(f['generatedPeaks'], d.generatedPeaks)), |
| 581 | ruggedness: unit(f['ruggedness'], d.ruggedness), |
| 582 | erosion: unit(f['erosion'], d.erosion), |
| 583 | 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)), |
| 591 | rimM: Math.max(0, num(f['rimM'], d.rimM)), |
| 592 | wind: parseWind(f['wind'], d.wind) } |
| 593 | case 'sea': |
| 594 | return { ...d, levelM: num(f['levelM'], d.levelM), |
| 595 | depthM: Math.max(1, num(f['depthM'], d.depthM)), |
| 596 | shoreM: Math.max(1, num(f['shoreM'], d.shoreM)), |
| 597 | wind: parseWind(f['wind'], d.wind), |
| 598 | ships: Math.max(0, Math.min(400, Math.floor(num(f['ships'], d.ships)))), |
| 599 | ice: parseIce(f['ice']) } |
| 600 | 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' } |
| 604 | 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 | } |
| 640 | case 'forest': |
| 641 | 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 | } |
| 649 | case 'region': |
| 650 | return { ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)) } |
| 651 | 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))) } |
| 654 | 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 |
| 657 | // 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 } |
| 660 | } |
| 661 | case 'road': |
| 662 | case 'path': |
| 663 | return { ...d, widthM: Math.max(0.5, num(f['widthM'], d.widthM)) } |
| 664 | case 'railway': |
| 665 | return { ...d, speedMS: Math.max(5, Math.min(200, num(f['speedMS'], d.speedMS))) } |
| 666 | default: |
| 667 | return Number.isFinite(f['heightM']) ? { ...d, heightM: f['heightM'] as number } : d |
| 668 | } |
| 669 | } |
| 670 | |
| 671 | function 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 | |
| 677 | function 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 | |
| 711 | function 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 | |
| 720 | /** A sea's ice, or null where it has none or the two edges are not two different places. */ |
| 721 | function 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 | |
| 731 | function parseGeometry(g: unknown): Geometry | null { |
| 732 | const src = g as { |
| 733 | type?: string; theta?: number; z?: number; points?: unknown |
| 734 | zFrom?: number; zTo?: number |
| 735 | } |
| 736 | 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 | } |
| 740 | if (src.type === 'band' && Number.isFinite(src.zFrom) && Number.isFinite(src.zTo)) { |
| 741 | return { type: 'band', zFrom: Math.min(src.zFrom!, src.zTo!), zTo: Math.max(src.zFrom!, src.zTo!) } |
| 742 | } |
| 743 | 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 | |
| 755 | /** A stable seed for a file that did not carry one, so it at least stays put. */ |
| 756 | export 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 | |
| 762 | /** |
| 763 | * 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). |
| 770 | */ |
| 771 | export function toPublicWorld(world: World): World { |
| 772 | return { |
| 773 | version: WORLD_VERSION, |
| 774 | seed: world.seed, |
| 775 | // Tower publishes its plans: the weather is public, revisions and their reasons too. |
| 776 | weather: world.weather, |
| 777 | calendar: world.calendar, |
| 778 | ground: { ...world.ground, notes: '' }, |
| 779 | features: world.features |
| 780 | .filter((f) => f.public) |
| 781 | .map((f) => ({ ...f, notes: '' })), |
| 782 | } |
| 783 | } |
| 784 | |
| 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 | */ |
| 789 | export 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 |
| 794 | // 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 | }), |
| 801 | }) |
| 802 | } |