fuzzyworld

tower / interior-flight / files / src / world / schema.ts

Branch interior-flight · default branch · all branches

schema.ts

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

e5555695 2026-09-07 Pavel Safronov1/**
2 * The world file. See design.md §3.2.
3 *
4 * Coordinates are canon (theta radians, z metres), never map pixels, so the world
5 * survives any change to how the map is drawn (decisions.md D-007, D-011).
6 *
7 * Two text fields, deliberately separate:
8 * blurb — public description, shipped to readers
9 * notes — the author's working notes, NEVER shipped
10 * plus `public: false` for a whole entry that is not ready to exist publicly.
11 */
12
13export const WORLD_VERSION = 1
14
15export const PLACE_KINDS = [
16 'city', 'town', 'embassy', 'tower', 'landmark', 'note',
17 'mountain', 'lake', 'river', 'canyon', 'forest', 'desert', 'region',
18] as const
19export type PlaceKind = (typeof PLACE_KINDS)[number]
20
21/** Kinds drawn as a filled area. */
22export const AREA_KINDS: readonly PlaceKind[] = ['lake', 'forest', 'desert', 'region']
23/** Kinds drawn as an open line — rivers and canyons have length but no interior. */
24export const LINE_KINDS: readonly PlaceKind[] = ['river', 'canyon']
25
26export const isAreaKind = (k: PlaceKind): boolean => AREA_KINDS.includes(k)
27export const isLineKind = (k: PlaceKind): boolean => LINE_KINDS.includes(k)
28/** Kinds drawn by tracing a path rather than by a single click. */
29export const isTracedKind = (k: PlaceKind): boolean => isAreaKind(k) || isLineKind(k)
30
31export type Geometry =
32 | { type: 'point'; theta: number; z: number }
33 | { type: 'polyline'; points: [number, number][] }
34 | { type: 'polygon'; points: [number, number][] }
04d6779e 2026-09-07 Pavel Safronov35 /**
36 * A slice of the whole circumference between two z. Not expressible as a
37 * polygon: theta 0 and theta 2pi normalise to the same map row, so a band
38 * written as four corners collapses into a hairline. Tower's desert and forest
39 * both ring the world, so this is a first-class shape here, not an edge case.
40 */
41 | { type: 'band'; zFrom: number; zTo: number }
e5555695 2026-09-07 Pavel Safronov42
43export interface Place {
44 id: string
45 name: string
46 kind: PlaceKind
47 geometry: Geometry
48 blurb: string
49 notes: string
50 public: boolean
51}
52
53export interface World {
54 version: number
55 places: Place[]
56}
57
58/** A place as it reaches readers: no working notes, no unpublished entries. */
59export interface PublicPlace {
60 id: string
61 name: string
62 kind: PlaceKind
63 geometry: Geometry
64 blurb: string
65}
66
67export const emptyWorld = (): World => ({ version: WORLD_VERSION, places: [] })
68
69export function makeId(name: string, taken: ReadonlySet<string>): string {
70 const base =
71 name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'place'
72 if (!taken.has(base)) return base
73 for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`
74}
75
76/**
77 * True if `id` looks auto-derived from `name` rather than pinned by the author.
78 * Ids are permalink targets, so they follow the name only while they still match
79 * it; once edited by hand they stop tracking. See src/ui/app.ts `rename`.
80 */
81export function isAutoId(id: string, name: string): boolean {
82 const slug = makeId(name, new Set())
83 return id === slug || new RegExp(`^${slug}-\\d+$`).test(id)
84}
85
86/**
87 * Accept anything shaped like a world, discarding entries that are not. Import
88 * handles files written by older versions and by hand, so it must not throw.
89 */
90export function parseWorld(raw: unknown): World {
91 const src = raw as Partial<World> | null
92 const places: Place[] = []
93 if (src && Array.isArray(src.places)) {
94 const seen = new Set<string>()
95 for (const p of src.places as Partial<Place>[]) {
96 const geometry = parseGeometry(p?.geometry)
97 if (!geometry) continue
98 const kind: PlaceKind = PLACE_KINDS.includes(p?.kind as PlaceKind)
99 ? (p!.kind as PlaceKind)
100 : 'landmark'
101 const name = typeof p?.name === 'string' && p.name.trim() ? p.name : 'Unnamed'
102 const id = typeof p?.id === 'string' && p.id && !seen.has(p.id) ? p.id : makeId(name, seen)
103 seen.add(id)
104 places.push({
105 id,
106 name,
107 kind,
108 geometry,
109 blurb: typeof p?.blurb === 'string' ? p.blurb : '',
110 notes: typeof p?.notes === 'string' ? p.notes : '',
111 public: p?.public !== false,
112 })
113 }
114 }
115 return { version: WORLD_VERSION, places }
116}
117
118function parseGeometry(g: unknown): Geometry | null {
04d6779e 2026-09-07 Pavel Safronov119 const src = g as {
120 type?: string; theta?: number; z?: number; points?: unknown
121 zFrom?: number; zTo?: number
122 }
e5555695 2026-09-07 Pavel Safronov123 if (!src) return null
124 if (src.type === 'point' && Number.isFinite(src.theta) && Number.isFinite(src.z)) {
125 return { type: 'point', theta: src.theta!, z: src.z! }
126 }
04d6779e 2026-09-07 Pavel Safronov127 if (src.type === 'band' && Number.isFinite(src.zFrom) && Number.isFinite(src.zTo)) {
128 return { type: 'band', zFrom: src.zFrom!, zTo: src.zTo! }
129 }
e5555695 2026-09-07 Pavel Safronov130 if ((src.type === 'polygon' || src.type === 'polyline') && Array.isArray(src.points)) {
131 const points = src.points
132 .filter((pt): pt is [number, number] =>
133 Array.isArray(pt) && pt.length === 2 && pt.every(Number.isFinite))
134 .map(([t, z]): [number, number] => [t, z])
135 // A polygon needs an interior; a polyline only needs to go somewhere.
136 const min = src.type === 'polygon' ? 3 : 2
137 if (points.length >= min) return { type: src.type, points }
138 }
139 return null
140}
141
142/**
143 * Strip a world down to what readers get. Drops non-public entries entirely and
144 * removes `notes` from the rest — see decisions.md D-011 on why this is not, by
145 * itself, sufficient privacy.
146 */
147export function toPublicWorld(world: World): { version: number; places: PublicPlace[] } {
148 return {
149 version: WORLD_VERSION,
150 places: world.places
151 .filter((p) => p.public)
152 .map(({ id, name, kind, geometry, blurb }) => ({ id, name, kind, geometry, blurb })),
153 }
154}