fuzzyworld

tower / commit

feat(world): world file, local persistence, exports and instruments

e5555695f909d47a7e83bb842f144157fa1c8c1a · Pavel Safronov · 2026-09-06 20:14:04 -0700 · parents ec7e78a8f91d

feat(world): world file, local persistence, exports and instruments

The author's loop is: edit in the browser with everything autosaved to
localStorage, export a full backup on whatever schedule suits, and export
the static public content once to seed the repo. No backend, because Pages
is static and the world belongs in git beside the prose.

Two exports, because the privacy boundary is real: the backup carries
private notes and unpublished places, the static export drops both. Tests
assert the notes field cannot reach the public file at all.

Place ids follow the name until edited by hand. They are permalink targets
so they must eventually be stable, but during world-building names churn and
a file full of new-city-7 is unreadable — so the author decides when an id
freezes, rather than the code deciding for them.

instruments.ts is the reason writer mode exists. The sightline probe answers
questions the novel actually raised, and two of its answers became canon:
the far cap is a 4% ghost from the midpoint, and the land 100 km overhead is
clearer than ground at the same distance, because a sightline across the
interior rises through thin axis air.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 src/world/instruments.ts |  78 +++++++++++++++++
 src/world/schema.ts      | 141 +++++++++++++++++++++++++++++++
 src/world/store.ts       | 178 +++++++++++++++++++++++++++++++++++++++
 test/instruments.test.ts |  76 +++++++++++++++++
 test/world.test.ts       | 213 +++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 686 insertions(+)

diff --git a/src/world/instruments.ts b/src/world/instruments.ts
new file mode 100644
index 0000000..52b36ef
--- /dev/null
+++ b/src/world/instruments.ts
@@ -0,0 +1,78 @@
+/**
+ * Measuring instruments. See design.md §3.3.
+ *
+ * These are the reason writer mode exists: the author asks questions like "can the
+ * far cap be seen from the midpoint?" and needs an answer that is computed rather
+ * than guessed. Two such answers became canon in design.md §1.8.
+ */
+import { extinctionAtRadius } from '../canon/atmosphere.js'
+import {
+  type SurfacePoint, toCartesian, surfaceDistance, directDistance,
+  chordApexHeight, routeCrossesSlat, landStripIndex,
+} from '../canon/geometry.js'
+
+export type Clarity = 'crisp' | 'hazy' | 'ghost' | 'invisible'
+
+export interface Sightline {
+  surfaceDistanceM: number
+  directDistanceM: number
+  /** How high above the floor the sightline rises. Why looking up beats looking along. */
+  apexHeightM: number
+  opticalDepth: number
+  transmission: number
+  clarity: Clarity
+}
+
+/**
+ * Rayleigh optical depth along the chord between two surface points, integrated
+ * through Tower's rotational density profile.
+ */
+export function opticalDepth(a: SurfacePoint, b: SurfacePoint, samples = 512): number {
+  const p0 = toCartesian(a)
+  const p1 = toCartesian(b)
+  const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2])
+  if (len === 0) return 0
+  const step = len / samples
+  let tau = 0
+  for (let i = 0; i < samples; i++) {
+    const t = (i + 0.5) / samples
+    const x = p0[0] + (p1[0] - p0[0]) * t
+    const y = p0[1] + (p1[1] - p0[1]) * t
+    tau += extinctionAtRadius(Math.hypot(x, y)) * step
+  }
+  return tau
+}
+
+export function clarityOf(opticalDepthValue: number): Clarity {
+  if (opticalDepthValue < 0.5) return 'crisp'
+  if (opticalDepthValue < 1.5) return 'hazy'
+  if (opticalDepthValue < 3.5) return 'ghost'
+  return 'invisible'
+}
+
+export function sightline(a: SurfacePoint, b: SurfacePoint): Sightline {
+  const tau = opticalDepth(a, b)
+  return {
+    surfaceDistanceM: surfaceDistance(a, b),
+    directDistanceM: directDistance(a, b),
+    apexHeightM: chordApexHeight(a, b),
+    opticalDepth: tau,
+    transmission: Math.exp(-tau),
+    clarity: clarityOf(tau),
+  }
+}
+
+export interface Travel {
+  distanceM: number
+  crossesSlat: boolean
+  /** Journey time in seconds at the given speed. */
+  durationS: number
+}
+
+export function travel(a: SurfacePoint, b: SurfacePoint, speedMS: number): Travel {
+  const distanceM = surfaceDistance(a, b)
+  return { distanceM, crossesSlat: routeCrossesSlat(a, b), durationS: distanceM / speedMS }
+}
+
+/** Which land strip a point sits on, or null for a slat. Useful for place metadata. */
+export { landStripIndex }
diff --git a/src/world/schema.ts b/src/world/schema.ts
new file mode 100644
index 0000000..cc0e593
--- /dev/null
+++ b/src/world/schema.ts
@@ -0,0 +1,141 @@
+/**
+ * The world file. See design.md §3.2.
+ *
+ * Coordinates are canon (theta radians, z metres), never map pixels, so the world
+ * survives any change to how the map is drawn (decisions.md D-007, D-011).
+ *
+ * Two text fields, deliberately separate:
+ *   blurb — public description, shipped to readers
+ *   notes — the author's working notes, NEVER shipped
+ * plus `public: false` for a whole entry that is not ready to exist publicly.
+ */
+
+export const WORLD_VERSION = 1
+
+export const PLACE_KINDS = [
+  'city', 'town', 'embassy', 'tower', 'landmark', 'note',
+  'mountain', 'lake', 'river', 'canyon', 'forest', 'desert', 'region',
+] as const
+export type PlaceKind = (typeof PLACE_KINDS)[number]
+
+/** Kinds drawn as a filled area. */
+export const AREA_KINDS: readonly PlaceKind[] = ['lake', 'forest', 'desert', 'region']
+/** Kinds drawn as an open line — rivers and canyons have length but no interior. */
+export const LINE_KINDS: readonly PlaceKind[] = ['river', 'canyon']
+
+export const isAreaKind = (k: PlaceKind): boolean => AREA_KINDS.includes(k)
+export const isLineKind = (k: PlaceKind): boolean => LINE_KINDS.includes(k)
+/** Kinds drawn by tracing a path rather than by a single click. */
+export const isTracedKind = (k: PlaceKind): boolean => isAreaKind(k) || isLineKind(k)
+
+export type Geometry =
+  | { type: 'point'; theta: number; z: number }
+  | { type: 'polyline'; points: [number, number][] }
+  | { type: 'polygon'; points: [number, number][] }
+
+export interface Place {
+  id: string
+  name: string
+  kind: PlaceKind
+  geometry: Geometry
+  blurb: string
+  notes: string
+  public: boolean
+}
+
+export interface World {
+  version: number
+  places: Place[]
+}
+
+/** A place as it reaches readers: no working notes, no unpublished entries. */
+export interface PublicPlace {
+  id: string
+  name: string
+  kind: PlaceKind
+  geometry: Geometry
+  blurb: string
+}
+
+export const emptyWorld = (): World => ({ version: WORLD_VERSION, places: [] })
+
+export function makeId(name: string, taken: ReadonlySet<string>): string {
+  const base =
+    name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'place'
+  if (!taken.has(base)) return base
+  for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`
+}
+
+/**
+ * True if `id` looks auto-derived from `name` rather than pinned by the author.
+ * Ids are permalink targets, so they follow the name only while they still match
+ * it; once edited by hand they stop tracking. See src/ui/app.ts `rename`.
+ */
+export function isAutoId(id: string, name: string): boolean {
+  const slug = makeId(name, new Set())
+  return id === slug || new RegExp(`^${slug}-\\d+$`).test(id)
+}
+
+/**
+ * Accept anything shaped like a world, discarding entries that are not. Import
+ * handles files written by older versions and by hand, so it must not throw.
+ */
+export function parseWorld(raw: unknown): World {
+  const src = raw as Partial<World> | null
+  const places: Place[] = []
+  if (src && Array.isArray(src.places)) {
+    const seen = new Set<string>()
+    for (const p of src.places as Partial<Place>[]) {
+      const geometry = parseGeometry(p?.geometry)
+      if (!geometry) continue
+      const kind: PlaceKind = PLACE_KINDS.includes(p?.kind as PlaceKind)
+        ? (p!.kind as PlaceKind)
+        : 'landmark'
+      const name = typeof p?.name === 'string' && p.name.trim() ? p.name : 'Unnamed'
+      const id = typeof p?.id === 'string' && p.id && !seen.has(p.id) ? p.id : makeId(name, seen)
+      seen.add(id)
+      places.push({
+        id,
+        name,
+        kind,
+        geometry,
+        blurb: typeof p?.blurb === 'string' ? p.blurb : '',
+        notes: typeof p?.notes === 'string' ? p.notes : '',
+        public: p?.public !== false,
+      })
+    }
+  }
+  return { version: WORLD_VERSION, places }
+}
+
+function parseGeometry(g: unknown): Geometry | null {
+  const src = g as { type?: string; theta?: number; z?: number; points?: unknown }
+  if (!src) return null
+  if (src.type === 'point' && Number.isFinite(src.theta) && Number.isFinite(src.z)) {
+    return { type: 'point', theta: src.theta!, z: src.z! }
+  }
+  if ((src.type === 'polygon' || src.type === 'polyline') && Array.isArray(src.points)) {
+    const points = src.points
+      .filter((pt): pt is [number, number] =>
+        Array.isArray(pt) && pt.length === 2 && pt.every(Number.isFinite))
+      .map(([t, z]): [number, number] => [t, z])
+    // A polygon needs an interior; a polyline only needs to go somewhere.
+    const min = src.type === 'polygon' ? 3 : 2
+    if (points.length >= min) return { type: src.type, points }
+  }
+  return null
+}
+
+/**
+ * Strip a world down to what readers get. Drops non-public entries entirely and
+ * removes `notes` from the rest — see decisions.md D-011 on why this is not, by
+ * itself, sufficient privacy.
+ */
+export function toPublicWorld(world: World): { version: number; places: PublicPlace[] } {
+  return {
+    version: WORLD_VERSION,
+    places: world.places
+      .filter((p) => p.public)
+      .map(({ id, name, kind, geometry, blurb }) => ({ id, name, kind, geometry, blurb })),
+  }
+}
diff --git a/src/world/store.ts b/src/world/store.ts
new file mode 100644
index 0000000..0bea9a1
--- /dev/null
+++ b/src/world/store.ts
@@ -0,0 +1,178 @@
+/**
+ * World persistence. See design.md §3.1.
+ *
+ * The author's loop is: edit in the browser, autosaved to localStorage constantly;
+ * export a full backup on whatever schedule they like; export the static public
+ * content once to seed the repo. There is no backend — GitLab Pages is static.
+ */
+import {
+  type Place, type World, emptyWorld, parseWorld, toPublicWorld, WORLD_VERSION,
+} from './schema.js'
+
+const STORAGE_KEY = 'tower.world.v1'
+const SAVE_DEBOUNCE_MS = 400
+
+export type SaveState = 'saved' | 'saving' | 'error'
+type Listener = () => void
+
+export class WorldStore {
+  private world: World = emptyWorld()
+  private listeners = new Set<Listener>()
+  private saveTimer: ReturnType<typeof setTimeout> | null = null
+
+  saveState: SaveState = 'saved'
+  lastSaved: Date | null = null
+  /** Set when localStorage is unavailable (private window, blocked site data). */
+  storageError: string | null = null
+
+  // ------------------------------------------------------------------- lifecycle
+
+  /**
+   * Restore the author's local draft. Falls back to the committed `world.json` —
+   * the public content — so a fresh browser opens onto the published world rather
+   * than an empty one.
+   */
+  async load(seedUrl: string): Promise<void> {
+    const stored = this.readStorage()
+    if (stored) {
+      this.world = stored
+      this.emit()
+      return
+    }
+    try {
+      const res = await fetch(seedUrl)
+      if (res.ok) this.world = parseWorld(await res.json())
+    } catch {
+      // No seed file yet is entirely normal — start empty.
+    }
+    this.emit()
+  }
+
+  private readStorage(): World | null {
+    try {
+      const raw = localStorage.getItem(STORAGE_KEY)
+      return raw ? parseWorld(JSON.parse(raw)) : null
+    } catch (err) {
+      this.storageError = err instanceof Error ? err.message : String(err)
+      return null
+    }
+  }
+
+  subscribe(fn: Listener): () => void {
+    this.listeners.add(fn)
+    return () => this.listeners.delete(fn)
+  }
+
+  private emit(): void {
+    for (const fn of this.listeners) fn()
+  }
+
+  // ------------------------------------------------------------------ autosaving
+
+  private touch(): void {
+    this.saveState = 'saving'
+    this.emit()
+    if (this.saveTimer) clearTimeout(this.saveTimer)
+    this.saveTimer = setTimeout(() => this.flush(), SAVE_DEBOUNCE_MS)
+  }
+
+  /** Write immediately. Also called on pagehide, so nothing is lost on close. */
+  flush(): void {
+    if (this.saveTimer) {
+      clearTimeout(this.saveTimer)
+      this.saveTimer = null
+    }
+    try {
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(this.world))
+      this.saveState = 'saved'
+      this.lastSaved = new Date()
+      this.storageError = null
+    } catch (err) {
+      this.saveState = 'error'
+      this.storageError = err instanceof Error ? err.message : String(err)
+    }
+    this.emit()
+  }
+
+  // ------------------------------------------------------------------------ CRUD
+
+  get places(): readonly Place[] {
+    return this.world.places
+  }
+
+  get(id: string): Place | undefined {
+    return this.world.places.find((p) => p.id === id)
+  }
+
+  get takenIds(): ReadonlySet<string> {
+    return new Set(this.world.places.map((p) => p.id))
+  }
+
+  add(place: Place): Place {
+    this.world.places.push(place)
+    this.touch()
+    return place
+  }
+
+  update(id: string, patch: Partial<Omit<Place, 'id'>>): void {
+    const place = this.get(id)
+    if (!place) return
+    Object.assign(place, patch)
+    this.touch()
+  }
+
+  /**
+   * Change a place's id, keeping order. Ids are permalink targets, so this is
+   * deliberate and explicit rather than a side effect of `update`.
+   */
+  setId(oldId: string, newId: string): boolean {
+    if (oldId === newId || this.get(newId)) return false
+    const place = this.get(oldId)
+    if (!place) return false
+    place.id = newId
+    this.touch()
+    return true
+  }
+
+  remove(id: string): void {
+    const i = this.world.places.findIndex((p) => p.id === id)
+    if (i >= 0) {
+      this.world.places.splice(i, 1)
+      this.touch()
+    }
+  }
+
+  replaceAll(world: World): void {
+    this.world = world
+    this.touch()
+  }
+
+  // ------------------------------------------------------------------- transfer
+
+  /** Everything, including private notes and unpublished places. The backup. */
+  fullJSON(): string {
+    return JSON.stringify({ version: WORLD_VERSION, places: this.world.places }, null, 2)
+  }
+
+  /** Public places only, notes stripped. The file that ships in the repo. */
+  publicJSON(): string {
+    return JSON.stringify(toPublicWorld(this.world), null, 2)
+  }
+
+  publicCount(): number {
+    return this.world.places.filter((p) => p.public).length
+  }
+}
+
+/** Hand the browser a file to save. */
+export function download(filename: string, contents: string): void {
+  const url = URL.createObjectURL(new Blob([contents], { type: 'application/json' }))
+  const a = document.createElement('a')
+  a.href = url
+  a.download = filename
+  a.click()
+  URL.revokeObjectURL(url)
+}
+
+export const backupFilename = (now = new Date()): string =>
+  `tower-backup-${now.toISOString().slice(0, 10)}.json`
diff --git a/test/instruments.test.ts b/test/instruments.test.ts
new file mode 100644
index 0000000..88234d1
--- /dev/null
+++ b/test/instruments.test.ts
@@ -0,0 +1,76 @@
+/**
+ * The sightline probe reproduces the table in design.md §1.8 — the two-horizons
+ * result, which is canon and came out of this calculation in the first place.
+ */
+import { describe, expect, it } from 'vitest'
+import { LENGTH_M, RADIUS_M } from '../src/canon/constants.js'
+import { sightline, travel } from '../src/world/instruments.js'
+import { landStripCentre } from '../src/canon/geometry.js'
+import { setSlatCount } from '../src/canon/slats.js'
+import { SLAT_COUNT_DEFAULT } from '../src/canon/constants.js'
+
+const along = (m: number) => sightline({ theta: 0, z: 0 }, { theta: 0, z: m })
+/** A point `deg` around the cylinder at the same z — a sightline across the interior. */
+const across = (deg: number) =>
+  sightline({ theta: 0, z: 0 }, { theta: (deg * Math.PI) / 180, z: 0 })
+
+describe('§1.8 looking along the axis', () => {
+  it('is 31% clear at 100 km', () => expect(along(100_000).transmission).toBeCloseTo(0.31, 2))
+  it('leaves the far cap a 4% ghost from the midpoint', () => {
+    const s = along(LENGTH_M / 2)
+    expect(s.transmission).toBeCloseTo(0.044, 3)
+    expect(s.clarity).toBe('ghost')
+  })
+  it('closes the horizon between 100 and 150 km', () => {
+    expect(along(100_000).clarity).toBe('hazy')
+    expect(along(150_000).clarity).toBe('ghost')
+  })
+  it('never rises off the floor — the axial floor is flat', () =>
+    expect(along(100_000).apexHeightM).toBeCloseTo(0, 6))
+})
+
+describe('§1.8 looking across the interior', () => {
+  it('is 78% clear to the point directly overhead, 100 km away', () => {
+    const s = across(180)
+    expect(s.directDistanceM).toBeCloseTo(2 * RADIUS_M, 6)
+    expect(s.transmission).toBeCloseTo(0.78, 2)
+    expect(s.clarity).toBe('crisp')
+  })
+  it('stays crisp all the way round', () => {
+    for (const d of [30, 60, 90, 135, 180]) expect(across(d).clarity).toBe('crisp')
+  })
+  it('rises to 14.6 km at 90° — why looking up beats looking along', () =>
+    expect(across(90).apexHeightM / 1000).toBeCloseTo(14.6, 1))
+})
+
+describe('§1.8 the headline: overhead beats ground at the same distance', () => {
+  it('is more than twice as clear across the interior at 100 km', () => {
+    const axial = along(100_000)
+    const overhead = across(180)
+    expect(overhead.directDistanceM).toBeCloseTo(axial.directDistanceM, 0)
+    expect(overhead.transmission).toBeGreaterThan(axial.transmission * 2)
+  })
+  it('gets clearer with distance across, the opposite of along', () => {
+    expect(across(180).transmission).toBeGreaterThan(across(60).transmission)
+    expect(along(150_000).transmission).toBeLessThan(along(50_000).transmission)
+  })
+})
+
+describe('travel', () => {
+  it('reports no slat crossing with the hull sealed (D-017)', () => {
+    const t = travel({ theta: 0, z: 0 }, { theta: Math.PI, z: 0 }, 1.4)
+    expect(t.crossesSlat).toBe(false)
+  })
+
+  it('times a walk and notices slat crossings when slats exist', () => {
+    setSlatCount(8)
+    const t = travel(
+      { theta: landStripCentre(0), z: 0 },
+      { theta: landStripCentre(1), z: 0 },
+      1.4,
+    )
+    expect(t.crossesSlat).toBe(true)
+    expect(t.durationS).toBeCloseTo(t.distanceM / 1.4, 6)
+    setSlatCount(SLAT_COUNT_DEFAULT)
+  })
+})
diff --git a/test/world.test.ts b/test/world.test.ts
new file mode 100644
index 0000000..671971f
--- /dev/null
+++ b/test/world.test.ts
@@ -0,0 +1,213 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+  type Place, PLACE_KINDS, isAreaKind, isAutoId, isLineKind, isTracedKind,
+  makeId, parseWorld, toPublicWorld,
+} from '../src/world/schema.js'
+import { readFileSync } from 'node:fs'
+import { WorldStore, backupFilename } from '../src/world/store.js'
+
+const place = (over: Partial<Place> = {}): Place => ({
+  id: 'p', name: 'P', kind: 'city',
+  geometry: { type: 'point', theta: 1, z: 2 },
+  blurb: '', notes: '', public: true, ...over,
+})
+
+describe('ids', () => {
+  it('slugifies and de-duplicates', () => {
+    expect(makeId('Hallow Crossing', new Set())).toBe('hallow-crossing')
+    expect(makeId('Hallow Crossing', new Set(['hallow-crossing']))).toBe('hallow-crossing-2')
+    expect(makeId('!!!', new Set())).toBe('place')
+  })
+})
+
+describe('isAutoId decides whether an id should follow a rename', () => {
+  it('recognises a plain slug of the name', () => {
+    expect(isAutoId('new-city', 'New city')).toBe(true)
+    expect(isAutoId('hallow-crossing', 'Hallow Crossing')).toBe(true)
+  })
+  it('recognises a de-duplicated slug', () => {
+    expect(isAutoId('new-city-2', 'New city')).toBe(true)
+    expect(isAutoId('new-city-17', 'New city')).toBe(true)
+  })
+  it('leaves a hand-pinned id alone', () => {
+    expect(isAutoId('rimfall-permanent', 'Rimfall')).toBe(false)
+    expect(isAutoId('new-city-x', 'New city')).toBe(false)
+    expect(isAutoId('anything', 'New city')).toBe(false)
+  })
+})
+
+describe('parseWorld tolerates hand-edited and older files', () => {
+  it('survives junk without throwing', () => {
+    for (const junk of [null, 42, {}, { places: 'no' }, { places: [null, 7] }]) {
+      expect(parseWorld(junk).places).toEqual([])
+    }
+  })
+  it('drops entries with unusable geometry', () => {
+    expect(parseWorld({ places: [
+      { name: 'ok', geometry: { type: 'point', theta: 1, z: 2 } },
+      { name: 'nan', geometry: { type: 'point', theta: NaN, z: 2 } },
+      { name: 'short', geometry: { type: 'polygon', points: [[0, 0], [1, 1]] } },
+    ] }).places.map((p) => p.name)).toEqual(['ok'])
+  })
+  it('defaults a missing public flag to true but honours an explicit false', () => {
+    const w = parseWorld({ places: [
+      { name: 'a', geometry: { type: 'point', theta: 0, z: 0 } },
+      { name: 'b', public: false, geometry: { type: 'point', theta: 0, z: 0 } },
+    ] })
+    expect(w.places.map((p) => p.public)).toEqual([true, false])
+  })
+  it('never lets two places share an id', () => {
+    const w = parseWorld({ places: [
+      { id: 'x', name: 'a', geometry: { type: 'point', theta: 0, z: 0 } },
+      { id: 'x', name: 'b', geometry: { type: 'point', theta: 0, z: 0 } },
+    ] })
+    expect(new Set(w.places.map((p) => p.id)).size).toBe(2)
+  })
+})
+
+describe('geometry kinds', () => {
+  it('classifies areas, lines and markers', () => {
+    expect(isAreaKind('lake')).toBe(true)
+    expect(isLineKind('river')).toBe(true)
+    expect(isLineKind('lake')).toBe(false)
+    expect(isTracedKind('canyon')).toBe(true)
+    expect(isTracedKind('city')).toBe(false)
+  })
+  it('accepts a polyline of two points but a polygon needs three', () => {
+    const two = [[0, 0], [1, 1]]
+    expect(parseWorld({ places: [
+      { name: 'line', geometry: { type: 'polyline', points: two } },
+      { name: 'poly', geometry: { type: 'polygon', points: two } },
+    ] }).places.map((p) => p.name)).toEqual(['line'])
+  })
+  it('round-trips a polyline through the public export', () => {
+    const pts: [number, number][] = [[0.1, 1000], [0.2, 2000], [0.3, 3000]]
+    const w = parseWorld({ places: [
+      { id: 'r', name: 'R', kind: 'river', geometry: { type: 'polyline', points: pts } }] })
+    expect(w.places[0]!.geometry).toEqual({ type: 'polyline', points: pts })
+    expect(toPublicWorld(w).places[0]!.geometry).toEqual({ type: 'polyline', points: pts })
+  })
+})
+
+describe('the seeded world loads without losing anything', () => {
+  const seed = JSON.parse(readFileSync('public/world.json', 'utf8'))
+
+  it('keeps every place the terrain tool emits', () => {
+    expect(parseWorld(seed).places).toHaveLength(seed.places.length)
+  })
+  it('knows every kind and geometry the tool uses', () => {
+    for (const p of seed.places) {
+      expect(PLACE_KINDS).toContain(p.kind)
+      expect(['point', 'polyline', 'polygon']).toContain(p.geometry.type)
+    }
+  })
+  it('keeps the river and the canyon, which are polylines', () => {
+    const names = parseWorld(seed).places.map((p) => p.name)
+    expect(names).toContain('The Long Water')
+    expect(names).toContain('The Narrows')
+  })
+})
+
+describe('the public export is the privacy boundary', () => {
+  const world = { version: 1, places: [
+    place({ id: 'a', name: 'Shown', notes: 'SECRET', blurb: 'visible' }),
+    place({ id: 'b', name: 'Hidden', public: false, notes: 'ALSO SECRET' }),
+  ] }
+
+  it('drops unpublished places entirely', () =>
+    expect(toPublicWorld(world).places.map((p) => p.id)).toEqual(['a']))
+
+  it('strips private notes from the places it keeps', () => {
+    const json = JSON.stringify(toPublicWorld(world))
+    expect(json).not.toContain('SECRET')
+    expect(json).not.toContain('notes')
+    expect(json).toContain('visible')
+  })
+})
+
+describe('WorldStore', () => {
+  let store: WorldStore
+  beforeEach(() => {
+    const data = new Map<string, string>()
+    vi.stubGlobal('localStorage', {
+      getItem: (k: string) => data.get(k) ?? null,
+      setItem: (k: string, v: string) => void data.set(k, v),
+      removeItem: (k: string) => void data.delete(k),
+    })
+    store = new WorldStore()
+  })
+
+  it('persists across a reload', async () => {
+    store.add(place({ id: 'a', name: 'Kept' }))
+    store.flush()
+    const fresh = new WorldStore()
+    await fresh.load('/world.json')
+    expect(fresh.places.map((p) => p.name)).toEqual(['Kept'])
+  })
+
+  it('prefers the local draft over the committed seed', async () => {
+    store.add(place({ id: 'draft', name: 'Draft' }))
+    store.flush()
+    const fresh = new WorldStore()
+    vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({
+      places: [place({ id: 'seed', name: 'Seed' })] }) }))
+    await fresh.load('/world.json')
+    expect(fresh.places.map((p) => p.name)).toEqual(['Draft'])
+  })
+
+  it('seeds from the committed world when there is no draft', async () => {
+    const fresh = new WorldStore()
+    vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({
+      places: [place({ id: 'seed', name: 'Seed' })] }) }))
+    await fresh.load('/world.json')
+    expect(fresh.places.map((p) => p.name)).toEqual(['Seed'])
+  })
+
+  it('starts empty rather than failing when there is no seed file', async () => {
+    const fresh = new WorldStore()
+    vi.stubGlobal('fetch', async () => { throw new Error('404') })
+    await expect(fresh.load('/world.json')).resolves.toBeUndefined()
+    expect(fresh.places).toEqual([])
+  })
+
+  it('reports rather than throws when localStorage is unavailable', () => {
+    vi.stubGlobal('localStorage', {
+      getItem: () => null,
+      setItem: () => { throw new Error('QuotaExceededError') },
+    })
+    const s = new WorldStore()
+    s.add(place())
+    s.flush()
+    expect(s.saveState).toBe('error')
+    expect(s.storageError).toContain('Quota')
+  })
+
+  it('keeps everything in the backup and only public data in the static export', () => {
+    store.add(place({ id: 'a', name: 'Pub', notes: 'SECRET' }))
+    store.add(place({ id: 'b', name: 'Priv', public: false }))
+    expect(store.fullJSON()).toContain('SECRET')
+    expect(store.fullJSON()).toContain('Priv')
+    expect(store.publicJSON()).not.toContain('SECRET')
+    expect(store.publicJSON()).not.toContain('Priv')
+    expect(store.publicCount()).toBe(1)
+  })
+
+  it('renames ids, since they are permalink targets', () => {
+    store.add(place({ id: 'new-city', name: 'New city' }))
+    expect(store.setId('new-city', 'hallow-crossing')).toBe(true)
+    expect(store.get('hallow-crossing')?.name).toBe('New city')
+    expect(store.get('new-city')).toBeUndefined()
+  })
+
+  it('refuses an id collision rather than losing a place', () => {
+    store.add(place({ id: 'a' }))
+    store.add(place({ id: 'b' }))
+    expect(store.setId('a', 'b')).toBe(false)
+    expect(store.places).toHaveLength(2)
+    expect(store.get('a')).toBeDefined()
+  })
+
+  it('dates backup filenames so a schedule of them sorts', () => {
+    expect(backupFilename(new Date('2026-09-05T12:00:00Z'))).toBe('tower-backup-2026-09-05.json')
+  })
+})