tower / interior-flight / files / src / world / schema.ts
Branch interior-flight · default branch · all branches
schema.ts
e5555695 2026-09-07 Pavel Safronov | 1 | /** |
| 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 | ||
| 13 | export const WORLD_VERSION = 1 | |
| 14 | ||
| 15 | export const PLACE_KINDS = [ | |
| 16 | 'city', 'town', 'embassy', 'tower', 'landmark', 'note', | |
| 17 | 'mountain', 'lake', 'river', 'canyon', 'forest', 'desert', 'region', | |
| 18 | ] as const | |
| 19 | export type PlaceKind = (typeof PLACE_KINDS)[number] | |
| 20 | ||
| 21 | /** Kinds drawn as a filled area. */ | |
| 22 | export const AREA_KINDS: readonly PlaceKind[] = ['lake', 'forest', 'desert', 'region'] | |
| 23 | /** Kinds drawn as an open line — rivers and canyons have length but no interior. */ | |
| 24 | export const LINE_KINDS: readonly PlaceKind[] = ['river', 'canyon'] | |
| 25 | ||
| 26 | export const isAreaKind = (k: PlaceKind): boolean => AREA_KINDS.includes(k) | |
| 27 | export const isLineKind = (k: PlaceKind): boolean => LINE_KINDS.includes(k) | |
| 28 | /** Kinds drawn by tracing a path rather than by a single click. */ | |
| 29 | export const isTracedKind = (k: PlaceKind): boolean => isAreaKind(k) || isLineKind(k) | |
| 30 | ||
| 31 | export 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 Safronov | 35 | /** |
| 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 Safronov | 42 | |
| 43 | export 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 | ||
| 53 | export interface World { | |
| 54 | version: number | |
| 55 | places: Place[] | |
| 56 | } | |
| 57 | ||
| 58 | /** A place as it reaches readers: no working notes, no unpublished entries. */ | |
| 59 | export interface PublicPlace { | |
| 60 | id: string | |
| 61 | name: string | |
| 62 | kind: PlaceKind | |
| 63 | geometry: Geometry | |
| 64 | blurb: string | |
| 65 | } | |
| 66 | ||
| 67 | export const emptyWorld = (): World => ({ version: WORLD_VERSION, places: [] }) | |
| 68 | ||
| 69 | export 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 | */ | |
| 81 | export 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 | */ | |
| 90 | export 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 | ||
| 118 | function parseGeometry(g: unknown): Geometry | null { | |
04d6779e 2026-09-07 Pavel Safronov | 119 | const src = g as { |
| 120 | type?: string; theta?: number; z?: number; points?: unknown | |
| 121 | zFrom?: number; zTo?: number | |
| 122 | } | |
e5555695 2026-09-07 Pavel Safronov | 123 | 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 Safronov | 127 | 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 Safronov | 130 | 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 | */ | |
| 147 | export 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 | } |