fuzzyworld

tower / commit

fix(world): an empty local draft no longer shadows the published world

3b520afd8f742669c045bceca1d9a3c19154c85f · Pavel Safronov · 2026-09-06 21:01:42 -0700 · parents 11a13dbf3b7e

fix(world): an empty local draft no longer shadows the published world

The map showed one place where twenty-two were expected. world.json
originally shipped empty, so opening the app in that window persisted an
empty world to localStorage — pagehide flushes on close — and from then on
load() saw a draft, took it, and never fetched the seed again. The published
world was in the served file the whole time and nothing in the UI hinted
otherwise, because an empty world and a deliberately emptied world look
exactly alike.

A stored world with no places is now treated as no draft. Nothing is
protected by keeping an empty one, and the opposite failure — a deliberately
emptied world refilling on reload — is visible and one click to undo, where
this failure was silent and permanent.

Also adds "Reload published world" for the case where a draft has drifted
rather than emptied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 decisions.md       | 30 ++++++++++++++++++++++++++++++
 index.html         |  1 +
 src/ui/app.ts      | 10 ++++++++++
 src/world/store.ts | 19 +++++++++++++++----
 test/world.test.ts | 20 ++++++++++++++++++++
 5 files changed, 76 insertions(+), 4 deletions(-)

diff --git a/decisions.md b/decisions.md
index 47ba90d..2506936 100644
--- a/decisions.md
+++ b/decisions.md
@@ -772,3 +772,33 @@ every light and the map would otherwise be legible only on a sliver.
 
 **Also fixed here:** selecting a place from the list moved the panel but not the
 camera — only permalinks called `focusOn`. Both paths now do.
+
+---
+
+## D-025 — An empty draft is not a draft
+
+**Fixed after the author hit it.** The map showed one place where twenty-two were
+expected. The local draft was empty and, because any draft beat the seed, the
+published world had been shadowed since the first time the app was opened.
+
+**How it happened.** `public/world.json` originally shipped as `{"places": []}`.
+Opening the app once in that window persisted an empty world to `localStorage` —
+`pagehide` flushes on close — and from then on `load()` saw a draft, took it, and
+never fetched the seed again. Twenty-two places were sitting in the served file, and
+nothing in the UI suggested anything was wrong: an empty world and a deliberately
+emptied world look identical.
+
+**The fix.** A stored world with no places is treated as no draft, so the seed
+loads. There is nothing to protect in an empty world, and the opposite failure — a
+deliberately emptied world refilling on reload — is visible and one click to undo,
+where this one was silent and permanent.
+
+**Plus an explicit way back.** *Reload published world* in the Data section discards
+the draft and refetches `world.json`, for the case where a draft has drifted rather
+than emptied. Verified end to end: an empty draft now recovers 22 places on reload,
+and reseeding after a local edit returns 23 to 22.
+
+**The general shape is worth remembering.** Local-first storage that silently wins
+over shipped content will, sooner or later, win with nothing in it. Precedence rules
+between a cache and its source need a defined answer for the empty case, and a
+manual override for every other case.
diff --git a/index.html b/index.html
index 0e270bc..eeffe22 100644
--- a/index.html
+++ b/index.html
@@ -98,6 +98,7 @@
       <button id="export-backup">Export backup <span class="dim">— everything</span></button>
       <button id="export-static">Export static map content <span class="dim">— public only</span></button>
       <button id="import">Import backup…</button>
+      <button id="reseed">Reload published world</button>
       <input id="import-file" type="file" accept="application/json" hidden />
       <p class="hint">
         Backups carry your private notes; keep them out of a public repo.
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 941bdb3..a39e644 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -484,6 +484,16 @@ export class App {
       this.store.flush()
       download('world.json', this.store.publicJSON())
     }
+    $('reseed').onclick = async () => {
+      const n = this.store.places.length
+      if (n && !confirm(`Replace ${n} local place(s) with the published world?`)) return
+      const loaded = await this.store.seedFrom(`${import.meta.env.BASE_URL}world.json`)
+      this.selectedId = null
+      this.store.flush()
+      this.render()
+      if (!loaded) alert('No published world.json was found to load.')
+    }
+
     const file = $<HTMLInputElement>('import-file')
     $('import').onclick = () => file.click()
     file.onchange = async () => {
diff --git a/src/world/store.ts b/src/world/store.ts
index 0bea9a1..3a5d6cd 100644
--- a/src/world/store.ts
+++ b/src/world/store.ts
@@ -28,17 +28,27 @@ export class WorldStore {
   // ------------------------------------------------------------------- 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.
+   * Restore the author's local draft, falling back to the committed `world.json`.
+   *
+   * An *empty* draft counts as no draft. Opening the app once while the seed was
+   * empty used to persist an empty world, which then shadowed the real one
+   * permanently — the published map was right there and simply never loaded. There
+   * is nothing to protect in a world with no places, and the alternative failure
+   * (a deliberately emptied world refilling on reload) is visible and one click to
+   * undo, where this one was silent.
    */
   async load(seedUrl: string): Promise<void> {
     const stored = this.readStorage()
-    if (stored) {
+    if (stored && stored.places.length > 0) {
       this.world = stored
       this.emit()
       return
     }
+    await this.seedFrom(seedUrl)
+  }
+
+  /** Replace the world with the published one. Also the manual recovery path. */
+  async seedFrom(seedUrl: string): Promise<number> {
     try {
       const res = await fetch(seedUrl)
       if (res.ok) this.world = parseWorld(await res.json())
@@ -46,6 +56,7 @@ export class WorldStore {
       // No seed file yet is entirely normal — start empty.
     }
     this.emit()
+    return this.world.places.length
   }
 
   private readStorage(): World | null {
diff --git a/test/world.test.ts b/test/world.test.ts
index c1a55e8..0becafc 100644
--- a/test/world.test.ts
+++ b/test/world.test.ts
@@ -145,6 +145,26 @@ describe('WorldStore', () => {
     expect(fresh.places.map((p) => p.name)).toEqual(['Kept'])
   })
 
+  it('treats an empty draft as no draft and loads the seed instead', async () => {
+    // Opening the app once while the seed was empty used to persist an empty world
+    // that then shadowed the real one forever.
+    store.replaceAll({ version: 1, places: [] })
+    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(['Seed'])
+  })
+
+  it('reseeds on demand, replacing whatever is there', async () => {
+    store.add(place({ id: 'local', name: 'Local' }))
+    vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({
+      places: [place({ id: 'a', name: 'A' }), place({ id: 'b', name: 'B' })] }) }))
+    await expect(store.seedFrom('/world.json')).resolves.toBe(2)
+    expect(store.places.map((p) => p.name)).toEqual(['A', 'B'])
+  })
+
   it('prefers the local draft over the committed seed', async () => {
     store.add(place({ id: 'draft', name: 'Draft' }))
     store.flush()