tower / cascadia / files / src / world / schema.ts
Branch cascadia · default branch · all branches
schema.ts
| 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][] } |
| 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 } |
| 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 { |
| 119 | const src = g as { |
| 120 | type?: string; theta?: number; z?: number; points?: unknown |
| 121 | zFrom?: number; zTo?: number |
| 122 | } |
| 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 | } |
| 127 | if (src.type === 'band' && Number.isFinite(src.zFrom) && Number.isFinite(src.zTo)) { |
| 128 | return { type: 'band', zFrom: src.zFrom!, zTo: src.zTo! } |
| 129 | } |
| 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 | } |