fuzzyworld

tower / files / tools / port-world.mjs

port-world.mjs

5.4 KB · history · blame

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 */
15import { readFileSync, writeFileSync } from 'node:fs'
16
17const geo = JSON.parse(readFileSync(process.argv[2] ?? 'content/geography.json', 'utf8'))
18const R = geo.datum.radiusM
19const 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. */
22const 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
28const round = (x, dp) => Number(x.toFixed(dp))
29
30/** v1 ellipses become polygons: the generators take any outline now. */
31function 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. */
41const ridgeBand = (f) => ({ type: 'band', zFrom: f.geometry.z - 2 * f.relief.widthZM, zTo: f.geometry.z + 2 * f.relief.widthZM })
42
43function common(f) {
44 return { id: f.id, name: f.name, blurb: '', notes: f.notes ?? '', public: true, seed: hashString(f.id) }
45}
46
47function 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
99const plain = byId.get('the-plain')
100const 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
107const 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
110writeFileSync('content/world.json', json(world))
111writeFileSync('static/world.json', json({
112 ...world,
113 ground: { ...world.ground, notes: '' },
114 features: world.features.filter((f) => f.public).map((f) => ({ ...f, notes: '' })),
115}))
116console.log(`ported ${world.features.length} features into content/world.json and static/world.json`)