fuzzyworld

tower / files / test / world.test.ts

world.test.ts

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

9d5a061c 2026-09-27 Pavel Safronov1import { describe, expect, it } from 'vitest'
e5555695 2026-09-07 Pavel Safronov2import {
59c12827 2026-09-28 Pavel Safronov3 type Feature, FEATURE_KINDS, anchorOf, defaultCalendar, geometryTypesFor, isAreaKind, isLineKind,
376f2ce6 2026-09-16 Pavel Safronov4 isTracedKind, makeId, parseWorld, terrainSignature, toPublicWorld, withKind,
e5555695 2026-09-07 Pavel Safronov5} from '../src/world/schema.js'
6import { readFileSync } from 'node:fs'
7
376f2ce6 2026-09-16 Pavel Safronov8const place = (over: Partial<Feature> = {}): Feature => ({
9 ...withKind({
10 id: 'p', name: 'P', geometry: { type: 'point', theta: 1, z: 2 },
11 blurb: '', notes: '', public: true, seed: 7,
12 }, 'settlement'),
13 ...over,
14} as Feature)
15
16const v2 = (features: unknown[]) => ({ version: 2, seed: 1, features })
e5555695 2026-09-07 Pavel Safronov17
18describe('ids', () => {
19 it('slugifies and de-duplicates', () => {
20 expect(makeId('Hallow Crossing', new Set())).toBe('hallow-crossing')
21 expect(makeId('Hallow Crossing', new Set(['hallow-crossing']))).toBe('hallow-crossing-2')
22 expect(makeId('!!!', new Set())).toBe('place')
23 })
24})
25
376f2ce6 2026-09-16 Pavel Safronov26describe('parseWorld tolerates hand-edited files', () => {
e5555695 2026-09-07 Pavel Safronov27 it('survives junk without throwing', () => {
376f2ce6 2026-09-16 Pavel Safronov28 for (const junk of [null, 42, {}, { features: 'no' }, { features: [null, 7] }]) {
29 expect(parseWorld(junk).features).toEqual([])
e5555695 2026-09-07 Pavel Safronov30 }
31 })
376f2ce6 2026-09-16 Pavel Safronov32 it('reads a v1 file as empty rather than guessing at it', () => {
33 // No backwards compatibility (D-062): v1 held places, and tools/port-world.mjs
34 // is how the one v1 world that mattered crossed over.
35 expect(parseWorld({ version: 1, places: [
36 { name: 'x', kind: 'city', geometry: { type: 'point', theta: 0, z: 0 } }] }).features).toEqual([])
37 })
e5555695 2026-09-07 Pavel Safronov38 it('drops entries with unusable geometry', () => {
376f2ce6 2026-09-16 Pavel Safronov39 expect(parseWorld(v2([
40 { name: 'ok', kind: 'landmark', geometry: { type: 'point', theta: 1, z: 2 } },
41 { name: 'nan', kind: 'landmark', geometry: { type: 'point', theta: NaN, z: 2 } },
42 { name: 'short', kind: 'lake', geometry: { type: 'polygon', points: [[0, 0], [1, 1]] } },
43 ])).features.map((p) => p.name)).toEqual(['ok'])
44 })
45 it('drops a shape its kind cannot take', () => {
46 // A lake with no shore gives the generator nothing to fill.
47 expect(parseWorld(v2([
48 { name: 'dot lake', kind: 'lake', geometry: { type: 'point', theta: 1, z: 2 } },
49 ])).features).toEqual([])
e5555695 2026-09-07 Pavel Safronov50 })
51 it('defaults a missing public flag to true but honours an explicit false', () => {
376f2ce6 2026-09-16 Pavel Safronov52 const w = parseWorld(v2([
53 { name: 'a', kind: 'note', geometry: { type: 'point', theta: 0, z: 0 } },
54 { name: 'b', kind: 'note', public: false, geometry: { type: 'point', theta: 0, z: 0 } },
55 ]))
56 expect(w.features.map((p) => p.public)).toEqual([true, false])
57 })
58 it('never lets two features share an id', () => {
59 const w = parseWorld(v2([
60 { id: 'x', name: 'a', kind: 'note', geometry: { type: 'point', theta: 0, z: 0 } },
61 { id: 'x', name: 'b', kind: 'note', geometry: { type: 'point', theta: 0, z: 0 } },
62 ]))
63 expect(new Set(w.features.map((p) => p.id)).size).toBe(2)
64 })
65 it('fills every parameter a kind has from its defaults', () => {
66 const w = parseWorld(v2([
67 { id: 'r', name: 'R', kind: 'river', geometry: { type: 'polyline', points: [[0, 0], [0.1, 1000]] } },
68 ]))
69 const r = w.features[0]!
70 expect(r.kind).toBe('river')
71 if (r.kind !== 'river') return
72 expect(r.flowM3S).toBeGreaterThan(0)
73 expect(r.sourceM).toBe('terrain')
74 expect(r.outlet).toBe('auto')
75 })
76 it('keeps a seed it is given, and invents a stable one when it is not', () => {
77 const one = { id: 'k', name: 'K', kind: 'forest', geometry: { type: 'band', zFrom: 0, zTo: 1 } }
78 expect(parseWorld(v2([{ ...one, seed: 42 }])).features[0]!.seed).toBe(42)
79 const a = parseWorld(v2([one])).features[0]!.seed
80 expect(parseWorld(v2([one])).features[0]!.seed).toBe(a)
e5555695 2026-09-07 Pavel Safronov81 })
82})
83
84describe('geometry kinds', () => {
85 it('classifies areas, lines and markers', () => {
86 expect(isAreaKind('lake')).toBe(true)
87 expect(isLineKind('river')).toBe(true)
88 expect(isLineKind('lake')).toBe(false)
376f2ce6 2026-09-16 Pavel Safronov89 expect(isTracedKind('road')).toBe(true)
90 expect(isTracedKind('settlement')).toBe(false)
e5555695 2026-09-07 Pavel Safronov91 })
376f2ce6 2026-09-16 Pavel Safronov92 it('lets every kind draw the shape its tool traces', () => {
93 for (const k of FEATURE_KINDS) {
94 const first = geometryTypesFor(k)[0]
95 if (isAreaKind(k)) expect(['polygon', 'band']).toContain(first)
96 else if (isLineKind(k)) expect(first).toBe('polyline')
97 }
e5555695 2026-09-07 Pavel Safronov98 })
04d6779e 2026-09-07 Pavel Safronov99 it('keeps a band as a band, which a polygon cannot express', () => {
100 // theta 0 and theta 2pi normalise to the same map row, so four corners
101 // collapse to a hairline. Tower's desert and forest both ring the world.
376f2ce6 2026-09-16 Pavel Safronov102 const w = parseWorld(v2([
103 { id: 'd', name: 'D', kind: 'desert', geometry: { type: 'band', zFrom: 148000, zTo: 218000 } }]))
104 expect(w.features[0]!.geometry).toEqual({ type: 'band', zFrom: 148000, zTo: 218000 })
105 expect(toPublicWorld(w).features[0]!.geometry).toEqual({ type: 'band', zFrom: 148000, zTo: 218000 })
04d6779e 2026-09-07 Pavel Safronov106 })
107 it('rejects a band with unusable bounds', () => {
376f2ce6 2026-09-16 Pavel Safronov108 expect(parseWorld(v2([
109 { name: 'x', kind: 'region', geometry: { type: 'band', zFrom: NaN, zTo: 1 } }])).features).toEqual([])
110 })
111 it('anchors a range on its first summit, not the corner of its outline', () => {
112 const w = parseWorld(v2([{
113 id: 'm', name: 'M', kind: 'mountains',
114 geometry: { type: 'polygon', points: [[0, 0], [0.2, 0], [0.2, 9000], [0, 9000]] },
115 peaks: [{ name: 'Top', theta: 0.1, z: 4500, elevationM: 3000 }],
116 }]))
117 expect(anchorOf(w.features[0]!)).toEqual({ theta: 0.1, z: 4500 })
04d6779e 2026-09-07 Pavel Safronov118 })
376f2ce6 2026-09-16 Pavel Safronov119})
04d6779e 2026-09-07 Pavel Safronov120
376f2ce6 2026-09-16 Pavel Safronov121describe('the terrain signature', () => {
122 const world = parseWorld(v2([
123 { id: 'l', name: 'Lake', kind: 'lake', geometry: { type: 'polygon', points: [[0, 0], [0.1, 0], [0.1, 1000]] } }]))
124 it('ignores what does not change the ground: names, blurbs, notes, publication', () => {
125 const renamed = structuredClone(world)
126 Object.assign(renamed.features[0]!, { id: 'pond', name: 'Pond', blurb: 'b', notes: 'n', public: false })
127 expect(terrainSignature(renamed)).toBe(terrainSignature(world))
128 })
129 it('changes with anything that does', () => {
130 const deeper = structuredClone(world)
131 Object.assign(deeper.features[0]!, { depthM: 999 })
132 expect(terrainSignature(deeper)).not.toBe(terrainSignature(world))
e5555695 2026-09-07 Pavel Safronov133 })
134})
135
376f2ce6 2026-09-16 Pavel Safronov136describe('the committed world loads without losing anything', () => {
137 const full = JSON.parse(readFileSync('content/world.json', 'utf8'))
9d5a061c 2026-09-27 Pavel Safronov138 // What the build serves as world.json (vite.config.ts): made from the file, never kept.
139 const pub = JSON.parse(JSON.stringify(toPublicWorld(parseWorld(full))))
e5555695 2026-09-07 Pavel Safronov140
9d5a061c 2026-09-27 Pavel Safronov141 it('keeps every feature in the file', () => {
376f2ce6 2026-09-16 Pavel Safronov142 expect(parseWorld(full).features).toHaveLength(full.features.length)
e5555695 2026-09-07 Pavel Safronov143 })
376f2ce6 2026-09-16 Pavel Safronov144 it('knows every kind and geometry in it', () => {
145 for (const f of full.features) {
146 expect(FEATURE_KINDS).toContain(f.kind)
147 expect(geometryTypesFor(f.kind)).toContain(f.geometry.type)
e5555695 2026-09-07 Pavel Safronov148 }
149 })
150 it('keeps the river and the canyon, which are polylines', () => {
376f2ce6 2026-09-16 Pavel Safronov151 const names = parseWorld(full).features.map((p) => p.name)
e5555695 2026-09-07 Pavel Safronov152 expect(names).toContain('The Long Water')
153 expect(names).toContain('The Narrows')
154 })
04d6779e 2026-09-07 Pavel Safronov155 it('keeps the ringing regions as bands', () => {
376f2ce6 2026-09-16 Pavel Safronov156 const byName = new Map(parseWorld(full).features.map((p) => [p.name, p]))
157 for (const n of ['The Dry Reach', 'Greatwood', 'The Sunward Sea', 'The Deepward Sea', 'The Girdle']) {
04d6779e 2026-09-07 Pavel Safronov158 expect(byName.get(n)?.geometry.type).toBe('band')
159 }
160 })
9d5a061c 2026-09-27 Pavel Safronov161 it('publishes every public feature, less its notes', () => {
376f2ce6 2026-09-16 Pavel Safronov162 expect(JSON.stringify(pub)).not.toContain('Whoever holds The Narrows')
9d5a061c 2026-09-27 Pavel Safronov163 expect(parseWorld(pub).features.map((f) => f.id))
164 .toEqual(full.features.filter((f: Feature) => f.public).map((f: Feature) => f.id))
376f2ce6 2026-09-16 Pavel Safronov165 })
e5555695 2026-09-07 Pavel Safronov166})
167
168describe('the public export is the privacy boundary', () => {
59c12827 2026-09-28 Pavel Safronov169 const world = { version: 2, seed: 1, weather: { cycleDays: 0, events: [] }, calendar: defaultCalendar(),
170 ground: { baseM: 60, reliefM: 260, notes: 'GROUND SECRET' },
376f2ce6 2026-09-16 Pavel Safronov171 features: [
172 place({ id: 'a', name: 'Shown', notes: 'SECRET', blurb: 'visible' }),
173 place({ id: 'b', name: 'Hidden', public: false, notes: 'ALSO SECRET' }),
174 ] }
e5555695 2026-09-07 Pavel Safronov175
376f2ce6 2026-09-16 Pavel Safronov176 it('drops unpublished features entirely', () =>
177 expect(toPublicWorld(world).features.map((p) => p.id)).toEqual(['a']))
e5555695 2026-09-07 Pavel Safronov178
376f2ce6 2026-09-16 Pavel Safronov179 it('strips private notes from everything it keeps, the ground included', () => {
e5555695 2026-09-07 Pavel Safronov180 const json = JSON.stringify(toPublicWorld(world))
181 expect(json).not.toContain('SECRET')
182 expect(json).toContain('visible')
183 })
184})