tower / commit
feat(world): world file, local persistence, exports and instruments
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') + }) +})