tower / commit
fix(world): an empty local draft no longer shadows the published world
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()