fuzzyworld

tower / cascadia / files / src / world / store.ts

Branch cascadia · default branch · all branches

store.ts

5.5 KB · history · blame

1/**
2 * World persistence. See design.md §3.1.
3 *
4 * The author's loop is: edit in the browser, autosaved to localStorage constantly;
5 * export a full backup on whatever schedule they like; export the static public
6 * content once to seed the repo. There is no backend — GitLab Pages is static.
7 */
8import {
9 type Place, type World, emptyWorld, parseWorld, toPublicWorld, WORLD_VERSION,
10} from './schema.js'
11
12const STORAGE_KEY = 'tower.world.v1'
13const SAVE_DEBOUNCE_MS = 400
14
15export type SaveState = 'saved' | 'saving' | 'error'
16type Listener = () => void
17
18export class WorldStore {
19 private world: World = emptyWorld()
20 private listeners = new Set<Listener>()
21 private saveTimer: ReturnType<typeof setTimeout> | null = null
22
23 saveState: SaveState = 'saved'
24 lastSaved: Date | null = null
25 /** Set when localStorage is unavailable (private window, blocked site data). */
26 storageError: string | null = null
27
28 // ------------------------------------------------------------------- lifecycle
29
30 /**
31 * Restore the author's local draft, falling back to the committed `world.json`.
32 *
33 * An *empty* draft counts as no draft. Opening the app once while the seed was
34 * empty used to persist an empty world, which then shadowed the real one
35 * permanently — the published map was right there and simply never loaded. There
36 * is nothing to protect in a world with no places, and the alternative failure
37 * (a deliberately emptied world refilling on reload) is visible and one click to
38 * undo, where this one was silent.
39 */
40 async load(seedUrl: string): Promise<void> {
41 const stored = this.readStorage()
42 if (stored && stored.places.length > 0) {
43 this.world = stored
44 this.emit()
45 return
46 }
47 await this.seedFrom(seedUrl)
48 }
49
50 /** Replace the world with the published one. Also the manual recovery path. */
51 async seedFrom(seedUrl: string): Promise<number> {
52 try {
53 const res = await fetch(seedUrl)
54 if (res.ok) this.world = parseWorld(await res.json())
55 } catch {
56 // No seed file yet is entirely normal — start empty.
57 }
58 this.emit()
59 return this.world.places.length
60 }
61
62 private readStorage(): World | null {
63 try {
64 const raw = localStorage.getItem(STORAGE_KEY)
65 return raw ? parseWorld(JSON.parse(raw)) : null
66 } catch (err) {
67 this.storageError = err instanceof Error ? err.message : String(err)
68 return null
69 }
70 }
71
72 subscribe(fn: Listener): () => void {
73 this.listeners.add(fn)
74 return () => this.listeners.delete(fn)
75 }
76
77 private emit(): void {
78 for (const fn of this.listeners) fn()
79 }
80
81 // ------------------------------------------------------------------ autosaving
82
83 private touch(): void {
84 this.saveState = 'saving'
85 this.emit()
86 if (this.saveTimer) clearTimeout(this.saveTimer)
87 this.saveTimer = setTimeout(() => this.flush(), SAVE_DEBOUNCE_MS)
88 }
89
90 /** Write immediately. Also called on pagehide, so nothing is lost on close. */
91 flush(): void {
92 if (this.saveTimer) {
93 clearTimeout(this.saveTimer)
94 this.saveTimer = null
95 }
96 try {
97 localStorage.setItem(STORAGE_KEY, JSON.stringify(this.world))
98 this.saveState = 'saved'
99 this.lastSaved = new Date()
100 this.storageError = null
101 } catch (err) {
102 this.saveState = 'error'
103 this.storageError = err instanceof Error ? err.message : String(err)
104 }
105 this.emit()
106 }
107
108 // ------------------------------------------------------------------------ CRUD
109
110 get places(): readonly Place[] {
111 return this.world.places
112 }
113
114 get(id: string): Place | undefined {
115 return this.world.places.find((p) => p.id === id)
116 }
117
118 get takenIds(): ReadonlySet<string> {
119 return new Set(this.world.places.map((p) => p.id))
120 }
121
122 add(place: Place): Place {
123 this.world.places.push(place)
124 this.touch()
125 return place
126 }
127
128 update(id: string, patch: Partial<Omit<Place, 'id'>>): void {
129 const place = this.get(id)
130 if (!place) return
131 Object.assign(place, patch)
132 this.touch()
133 }
134
135 /**
136 * Change a place's id, keeping order. Ids are permalink targets, so this is
137 * deliberate and explicit rather than a side effect of `update`.
138 */
139 setId(oldId: string, newId: string): boolean {
140 if (oldId === newId || this.get(newId)) return false
141 const place = this.get(oldId)
142 if (!place) return false
143 place.id = newId
144 this.touch()
145 return true
146 }
147
148 remove(id: string): void {
149 const i = this.world.places.findIndex((p) => p.id === id)
150 if (i >= 0) {
151 this.world.places.splice(i, 1)
152 this.touch()
153 }
154 }
155
156 replaceAll(world: World): void {
157 this.world = world
158 this.touch()
159 }
160
161 // ------------------------------------------------------------------- transfer
162
163 /** Everything, including private notes and unpublished places. The backup. */
164 fullJSON(): string {
165 return JSON.stringify({ version: WORLD_VERSION, places: this.world.places }, null, 2)
166 }
167
168 /** Public places only, notes stripped. The file that ships in the repo. */
169 publicJSON(): string {
170 return JSON.stringify(toPublicWorld(this.world), null, 2)
171 }
172
173 publicCount(): number {
174 return this.world.places.filter((p) => p.public).length
175 }
176}
177
178/** Hand the browser a file to save. */
179export function download(filename: string, contents: string): void {
180 const url = URL.createObjectURL(new Blob([contents], { type: 'application/json' }))
181 const a = document.createElement('a')
182 a.href = url
183 a.download = filename
184 a.click()
185 URL.revokeObjectURL(url)
186}
187
188export const backupFilename = (now = new Date()): string =>
189 `tower-backup-${now.toISOString().slice(0, 10)}.json`