tower / files / tools / port-world.mjs
port-world.mjs
376f2ce6 2026-09-16 Pavel Safronov | 1 | /** |
| 2 | * One-off port of the v1 geography into the v2 world file. `node tools/port-world.mjs` | |
| 3 | * | |
| 4 | * v1 kept the world in two files: content/geography.json (terrain ops and their | |
| 5 | * parameters, the author's notes) and static/world.json (places for the app). v2 is | |
| 6 | * one world of features, generated live (world-generation.md §3, D-062). There is no | |
| 7 | * backwards compatibility — this script is how the one world that mattered crossed | |
| 8 | * over, and it is kept only so the port can be read and rerun from git history. | |
| 9 | * | |
| 10 | * Writes: | |
| 11 | * content/world.json the whole world, author's notes included — the source of truth, | |
| 12 | * committed exactly as content/geography.json was | |
| 13 | * static/world.json what readers get: public features, every note stripped | |
| 14 | */ | |
| 15 | import { readFileSync, writeFileSync } from 'node:fs' | |
| 16 | ||
| 17 | const geo = JSON.parse(readFileSync(process.argv[2] ?? 'content/geography.json', 'utf8')) | |
| 18 | const R = geo.datum.radiusM | |
| 19 | const byId = new Map(geo.features.map((f) => [f.id, f])) | |
| 20 | ||
| 21 | /** FNV-1a, the same as schema.ts `hashString`, so seeds are what the app would pick. */ | |
| 22 | const hashString = (s) => { | |
| 23 | let h = 2166136261 | |
| 24 | for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619) | |
| 25 | return (h >>> 0) & 0x7fffffff | |
| 26 | } | |
| 27 | ||
| 28 | const round = (x, dp) => Number(x.toFixed(dp)) | |
| 29 | ||
| 30 | /** v1 ellipses become polygons: the generators take any outline now. */ | |
| 31 | function ellipse(g, radiusThetaM, radiusZM, n = 48) { | |
| 32 | const points = [] | |
| 33 | for (let i = 0; i < n; i++) { | |
| 34 | const t = (i / n) * 2 * Math.PI | |
| 35 | points.push([round(g.theta + (radiusThetaM / R) * Math.cos(t), 6), Math.round(g.z + radiusZM * Math.sin(t))]) | |
| 36 | } | |
| 37 | return { type: 'polygon', points } | |
| 38 | } | |
| 39 | ||
| 40 | /** v1 ridges were a centre line and a width; the Gaussian reached out to twice the width. */ | |
| 41 | const ridgeBand = (f) => ({ type: 'band', zFrom: f.geometry.z - 2 * f.relief.widthZM, zTo: f.geometry.z + 2 * f.relief.widthZM }) | |
| 42 | ||
| 43 | function common(f) { | |
| 44 | return { id: f.id, name: f.name, blurb: '', notes: f.notes ?? '', public: true, seed: hashString(f.id) } | |
| 45 | } | |
| 46 | ||
| 47 | function port(f) { | |
| 48 | const c = common(f) | |
| 49 | const op = f.relief?.op | |
| 50 | if (f.id === 'the-plain') return null | |
| 51 | if (op === 'flood' && f.geometry.type === 'band') { | |
| 52 | return { ...c, kind: 'sea', geometry: { type: 'band', zFrom: f.geometry.zFrom, zTo: f.geometry.zTo }, | |
| 53 | levelM: f.waterLevelM, depthM: f.relief.depthM, shoreM: f.relief.edgeM } | |
| 54 | } | |
| 55 | if (op === 'flood') { | |
| 56 | // The Deep: a rectangle in theta and z. | |
| 57 | const g = f.geometry | |
| 58 | return { ...c, kind: 'lake', geometry: { type: 'polygon', points: [ | |
| 59 | [g.thetaFrom, g.zFrom], [g.thetaTo, g.zFrom], [g.thetaTo, g.zTo], [g.thetaFrom, g.zTo]] }, | |
| 60 | levelM: f.waterLevelM, depthM: f.relief.depthM, basin: 'flooded', shoreM: f.relief.edgeM, rimM: 0 } | |
| 61 | } | |
| 62 | if (op === 'dome') { | |
| 63 | return { ...c, kind: 'mountains', | |
| 64 | geometry: ellipse(f.geometry, f.relief.radiusThetaM, f.relief.radiusZM), | |
| 65 | peaks: [{ name: f.name, theta: f.geometry.theta, z: f.geometry.z, elevationM: f.elevationM }], | |
| 66 | heightM: f.elevationM, ruggedness: f.relief.ridgedAmp, snowlineM: geo.render.snowlineM } | |
| 67 | } | |
| 68 | if (op === 'ridge') { | |
| 69 | return { ...c, kind: 'mountains', geometry: ridgeBand(f), peaks: [], | |
| 70 | heightM: f.relief.heightM, ruggedness: f.relief.ridgedAmp ?? 0, snowlineM: geo.render.snowlineM } | |
| 71 | } | |
| 72 | if (op === 'tarn') { | |
| 73 | return { ...c, kind: 'lake', geometry: ellipse(f.geometry, f.geometry.radiusThetaM, f.geometry.radiusZM, 32), | |
| 74 | levelM: 'auto', depthM: f.relief.depthM, basin: 'bowl', shoreM: 2000, rimM: f.relief.rimM } | |
| 75 | } | |
| 76 | if (op === 'plateau') { | |
| 77 | return { ...c, kind: f.kind === 'desert' ? 'desert' : 'forest', | |
| 78 | geometry: { type: 'band', zFrom: f.geometry.zFrom, zTo: f.geometry.zTo }, | |
| 79 | upliftM: f.relief.heightM, edgeM: f.relief.edgeM } | |
| 80 | } | |
| 81 | if (op === 'carve') { | |
| 82 | /** | |
| 83 | * Flow: The Narrows carries the whole world's 392 m³/s (design.md §1.11), and it | |
| 84 | * is fed by four rivers. Split evenly, the trunk's own share plus its three | |
| 85 | * tributaries' reaches 392 below the gap. The generator adds tributaries in. | |
| 86 | */ | |
| 87 | return { ...c, kind: 'river', geometry: f.geometry, | |
| 88 | flowM3S: geo.hydrology.meanFlowM3S / 4, sourceM: f.profileM[0], outlet: 'auto' } | |
| 89 | } | |
| 90 | if (f.kind === 'canyon') return { ...c, kind: 'landmark', geometry: f.geometry } | |
| 91 | if (f.kind === 'city') { | |
| 92 | return { ...c, kind: 'settlement', geometry: f.geometry, size: f.id === 'the-capital' ? 'city' : 'station' } | |
| 93 | } | |
| 94 | if (f.kind === 'tower') return { ...c, kind: 'tower', geometry: f.geometry, heightM: f.heightM } | |
| 95 | if (f.kind === 'landmark') return { ...c, kind: 'landmark', geometry: f.geometry } | |
| 96 | throw new Error(`no port for ${f.id} (${f.kind}, ${op})`) | |
| 97 | } | |
| 98 | ||
| 99 | const plain = byId.get('the-plain') | |
| 100 | const world = { | |
| 101 | version: 2, | |
| 102 | seed: 1729, | |
| 103 | ground: { baseM: plain.relief.baseM, reliefM: plain.relief.noiseAmpM, notes: plain.notes ?? '' }, | |
| 104 | features: geo.features.map(port).filter(Boolean), | |
| 105 | } | |
| 106 | ||
| 107 | const json = (w) => JSON.stringify(w, (_k, v) => (Array.isArray(v) && v.length === 2 && v.every(Number.isFinite) ? JSON.stringify(v) : v), 2) | |
| 108 | .replace(/"\[(-?[\d.e+-]+),(-?[\d.e+-]+)\]"/g, '[$1, $2]') + '\n' | |
| 109 | ||
| 110 | writeFileSync('content/world.json', json(world)) | |
| 111 | writeFileSync('static/world.json', json({ | |
| 112 | ...world, | |
| 113 | ground: { ...world.ground, notes: '' }, | |
| 114 | features: world.features.filter((f) => f.public).map((f) => ({ ...f, notes: '' })), | |
| 115 | })) | |
| 116 | console.log(`ported ${world.features.length} features into content/world.json and static/world.json`) |