| 1 | import { PetWorld, PET_MAX_SECONDS, type PetInteraction, type PetWorldCheckpoint } from '../core/pet-world.js'; |
| 2 | import { compilePetTelemetry, decodePetJSONL, PetLiveTape, type PetBucket } from '../core/pet-telemetry.js'; |
| 3 | import { petDemoEvents } from '../core/pet-demo.js'; |
| 4 | import { importTrace } from '../core/ingest.js'; |
| 5 | import { layout } from '../core/pet-sim.js'; |
| 6 | import { renderPetPCM, type PetVoice } from '../core/pet-audio.js'; |
| 7 | import { TraceLibrary, type SavedHabitat } from './storage.js'; |
| 8 | |
| 9 | const get = <T extends HTMLElement>(id: string) => document.getElementById(id) as T; |
| 10 | const canvas = get<HTMLCanvasElement>('tank'), ctx = canvas.getContext('2d')!; |
| 11 | const mode = get<HTMLSelectElement>('mode'), seek = get<HTMLInputElement>('seek'), motion = get<HTMLInputElement>('motion'); |
| 12 | const message = get('message'), source = get('source'); |
| 13 | const media = matchMedia('(prefers-reduced-motion: reduce)'); motion.checked = media.matches; |
| 14 | media.addEventListener('change', () => { motion.checked = media.matches; if (world) rebuild(seconds, undefined, world.recording(false).start); }); |
| 15 | let points: [number, number][] = [], tape: readonly PetBucket[] = [], interactions: PetInteraction[] = []; |
| 16 | let expressionVersion: 1 | 2 = 2; |
| 17 | let worldMode = 'wild'; |
| 18 | let world: PetWorld, seconds = 0, last = 0, accumulator = 0, paused = false; |
| 19 | let restoring = false, generation = 0, liveGeneration = 0, liveTimer = 0; |
| 20 | let imported: { world: PetWorld; name: string } | undefined; |
| 21 | const library = new TraceLibrary(); |
| 22 | const liveTape = new PetLiveTape(); |
| 23 | let liveObservation = 0; |
| 24 | let savedRevision: number | undefined, saving: Promise<boolean> | undefined, persistenceReady = false, persistenceFailed = false; |
| 25 | let audio: AudioContext | undefined, anchor = 0, sound = false; |
| 26 | const playing = new Set<AudioBufferSourceNode>(); |
| 27 | const duration = () => Number(seek.max); |
| 28 | const timeLabel = (n: number) => { |
| 29 | const minutes = Math.floor(n / 60), seconds = String(Math.floor(n % 60)).padStart(2, '0'); |
| 30 | return minutes < 60 ? `${minutes}:${seconds}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${seconds}`; |
| 31 | }; |
| 32 | |
| 33 | function silence() { for (const node of playing) { try { node.stop(); } catch { /* Already ended. */ } } playing.clear(); } |
| 34 | function play(voices: readonly PetVoice[]) { |
| 35 | if (!audio || !sound || paused) return; |
| 36 | for (const voice of voices) { |
| 37 | const start = Math.floor(voice.start * audio.sampleRate), length = Math.ceil(voice.duration * audio.sampleRate) + 2; |
| 38 | const pcm = renderPetPCM([voice], start, length, audio.sampleRate); |
| 39 | const buffer = audio.createBuffer(2, length, audio.sampleRate); |
| 40 | buffer.copyToChannel(pcm.left, 0); buffer.copyToChannel(pcm.right, 1); |
| 41 | const node = audio.createBufferSource(); node.buffer = buffer; node.connect(audio.destination); |
| 42 | const at = anchor + start / audio.sampleRate; |
| 43 | if (at + buffer.duration < audio.currentTime) continue; |
| 44 | playing.add(node); node.onended = () => { playing.delete(node); node.disconnect(); }; |
| 45 | node.start(Math.max(audio.currentTime, at), Math.max(0, audio.currentTime - at)); |
| 46 | } |
| 47 | } |
| 48 | async function rebuild(to = 0, checkpoint?: PetWorldCheckpoint, start?: PetWorldCheckpoint) { |
| 49 | const ticket = ++generation; |
| 50 | const next = start ? PetWorld.fromRecording(points, { petReplayVersion: 2, expressionVersion, tape, interactions, start, checkpoint }) |
| 51 | : checkpoint ? PetWorld.restore(points, tape, interactions, checkpoint) : new PetWorld(points, tape, interactions, expressionVersion, true); |
| 52 | const ticks = Math.round(Math.max(next.startTimeMs / 1000, Math.min(PET_MAX_SECONDS, to)) * 30); |
| 53 | if (checkpoint && checkpoint.tick !== ticks) throw new Error('Saved pet clock does not match its checkpoint.'); |
| 54 | silence(); restoring = true; |
| 55 | canvas.setAttribute('aria-busy', 'true'); |
| 56 | for (const id of ['save', 'attention', 'feed']) get<HTMLButtonElement>(id).disabled = true; |
| 57 | for (let i = Math.round(next.frame.timeMs * 30 / 1000); i < ticks; i++) { |
| 58 | next.step(1 / 30, { motion: !motion.checked, sensitivity: 1 }); |
| 59 | if (i > 0 && i % 600 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); if (ticket !== generation) return; } |
| 60 | } |
| 61 | if (ticket !== generation) return; |
| 62 | adoptWorld(next); |
| 63 | } |
| 64 | function adoptWorld(next: PetWorld) { |
| 65 | world = next; worldMode = mode.value; tape = world.tape; interactions = [...world.interactions]; |
| 66 | seek.min = String(next.startTimeMs / 1000); expressionVersion = next.sim.expressionVersion; restoring = false; |
| 67 | if (mode.value === 'replay') imported = { world: next, name: imported?.name ?? source.textContent ?? 'Imported replay' }; |
| 68 | canvas.setAttribute('aria-busy', 'false'); |
| 69 | for (const id of ['save', 'attention', 'feed']) get<HTMLButtonElement>(id).disabled = false; |
| 70 | seconds = next.frame.timeMs / 1000; accumulator = 0; last = 0; |
| 71 | if (audio) anchor = audio.currentTime - seconds + .08; |
| 72 | draw(); |
| 73 | } |
| 74 | function stopFollowing() { liveGeneration++; clearTimeout(liveTimer); liveTape.reset(); get<HTMLButtonElement>('pause').disabled = false; seek.disabled = false; } |
| 75 | async function refreshArchives() { |
| 76 | const list = get<HTMLSelectElement>('archive'); |
| 77 | const entries = await library.petArchives(); |
| 78 | list.replaceChildren(new Option('Earlier recordings…', '')); |
| 79 | for (const entry of entries) list.add(new Option(`${entry.name} · ${timeLabel(entry.startSeconds)}–${timeLabel(entry.seconds)}`, String(entry.key))); |
| 80 | } |
| 81 | async function persist(archiveCurrent = false): Promise<boolean> { |
| 82 | if (saving) { |
| 83 | if (!archiveCurrent) return saving; |
| 84 | await saving; |
| 85 | return persist(true); |
| 86 | } |
| 87 | if (!world || restoring || !persistenceReady || persistenceFailed) return false; |
| 88 | const task = saveCurrent(archiveCurrent); saving = task; |
| 89 | try { return await task; } |
| 90 | finally { if (saving === task) saving = undefined; } |
| 91 | } |
| 92 | async function saveCurrent(archiveCurrent: boolean): Promise<boolean> { |
| 93 | try { |
| 94 | const owner = world; |
| 95 | const snapshot: SavedHabitat = { petPersistenceVersion: 1, seconds: owner.frame.timeMs / 1000, |
| 96 | source: worldMode === 'live' ? 'replay' : worldMode as SavedHabitat['source'], |
| 97 | sourceName: worldMode === 'live' ? 'Saved live recording' : source.textContent ?? '', still: motion.checked, |
| 98 | ...owner.recording() }; |
| 99 | const segment = archiveCurrent || owner.needsSegment ? owner.prepareSegment() : undefined; |
| 100 | savedRevision = await library.saveHabitat(segment ? { ...snapshot, ...segment.recording } : snapshot, savedRevision, |
| 101 | archiveCurrent ? snapshot : segment ? { ...snapshot, ...segment.archive } : undefined); |
| 102 | if (segment) { |
| 103 | segment.commit(); |
| 104 | if (world === owner) { tape = owner.tape; interactions = [...owner.interactions]; seek.min = String(owner.startTimeMs / 1000); } |
| 105 | void refreshArchives().catch(() => {}); |
| 106 | } |
| 107 | get('persistence').textContent = 'Habitat saved on this device. Earlier recordings remain available.'; |
| 108 | return true; |
| 109 | } |
| 110 | catch (error) { persistenceFailed = true; get('persistence').textContent = error instanceof Error ? error.message : 'Unable to save the habitat. Save a replay file to keep it.'; return false; } |
| 111 | } |
| 112 | async function mayLeave(): Promise<boolean> { |
| 113 | return await persist(true) || window.confirm('This visit could not be saved. Cancel to keep it and export a replay, or leave without saving its latest progress.'); |
| 114 | } |
| 115 | function adoptImported(next: PetWorld, name: string) { |
| 116 | stopFollowing(); mode.value = 'replay'; |
| 117 | imported = { world: next, name }; source.textContent = name; |
| 118 | mode.querySelector<HTMLOptionElement>('[value="replay"]')!.disabled = false; |
| 119 | seek.max = String(Math.max(90, next.endTimeMs / 1000)); |
| 120 | ++generation; silence(); adoptWorld(next); |
| 121 | } |
| 122 | get<HTMLSelectElement>('archive').onchange = async event => { |
| 123 | const picker = event.target as HTMLSelectElement, key = Number(picker.value); picker.value = ''; |
| 124 | if (!key) return; |
| 125 | try { |
| 126 | const saved = await library.petArchive(key); |
| 127 | const next = PetWorld.fromRecording(points, { ...saved, petReplayVersion: saved.petReplayVersion ?? 1 }); |
| 128 | if (!await mayLeave()) return; |
| 129 | adoptImported(next, saved.sourceName); message.textContent = 'Earlier recording opened. Its original remains in local storage.'; |
| 130 | } catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to open this recording.'; } |
| 131 | }; |
| 132 | |
| 133 | function draw() { |
| 134 | if (!world) return; |
| 135 | const w = canvas.clientWidth, h = canvas.clientHeight, dpr = Math.min(2, devicePixelRatio || 1); |
| 136 | if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); } |
| 137 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); |
| 138 | const frame = world.frame, state = frame.state, sim = world.sim, l = layout(w, h - 65, state); |
| 139 | const t = motion.checked ? 0 : seconds; |
| 140 | ctx.lineWidth = 1; |
| 141 | for (let i = 0; i < 6; i++) { |
| 142 | ctx.strokeStyle = `rgba(98,169,190,${.025 + .016 * frame.caustic})`; ctx.beginPath(); |
| 143 | for (let x = 0; x <= w; x += 8) { const y = h * .82 + Math.sin(x / 110 + i + t * .18) * 8 + i * 6; if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); |
| 144 | } |
| 145 | ctx.strokeStyle = '#24424f'; ctx.beginPath(); ctx.moveTo(24, h * (.08 + frame.surface * .008)); ctx.lineTo(w - 24, h * (.08 + frame.surface * .008)); ctx.stroke(); |
| 146 | const f = sim.frame; |
| 147 | ctx.fillStyle = ctx.strokeStyle = `rgba(${Math.round(f.r)},${Math.round(f.g)},${Math.round(f.b)},${f.alpha})`; |
| 148 | for (const q of sim.p) { |
| 149 | ctx.beginPath(); ctx.arc(l.ox + q.x * l.scale * l.flipX, l.oy + q.y * l.scale, Math.max(.7, l.dot * .31), 0, Math.PI * 2); |
| 150 | if (state.observed < .92) ctx.stroke(); else ctx.fill(); |
| 151 | } |
| 152 | if (frame.food) { ctx.fillStyle = `rgba(210,198,146,${frame.food.life})`; ctx.beginPath(); ctx.arc(w * (.5 + frame.food.x * .3), h * (.5 + frame.food.y * .3), 3, 0, Math.PI * 2); ctx.fill(); } |
| 153 | get('channel').textContent = `${f.channel} · ${f.arch}${state.observed < .92 ? ' · unobserved' : ''}`; |
| 154 | get('behaviour').textContent = `${frame.behaviour}${frame.needs !== 'none' ? ' · awaiting input' : ''}${frame.pod.length >= 3 ? ` · ${frame.pod.filter(p => p.present).length} peers present` : ''}`; |
| 155 | canvas.setAttribute('aria-label', `Dot whale: ${f.channel}, ${f.arch}, ${frame.behaviour}${state.observed < .92 ? ', telemetry unobserved' : ''}.`); |
| 156 | seek.value = String(seconds); get('clock').textContent = timeLabel(seconds); get('end').textContent = timeLabel(duration()); |
| 157 | } |
| 158 | function interact(kind: PetInteraction['kind'], x = .2, y = -.15) { |
| 159 | if (restoring) return; |
| 160 | world.interact(kind, x, y); interactions = [...world.interactions]; |
| 161 | } |
| 162 | canvas.addEventListener('pointerdown', event => { const r = canvas.getBoundingClientRect(); interact('attention', Math.max(-1, Math.min(1, (event.clientX - r.left) / r.width * 2 - 1)), Math.max(-1, Math.min(1, (event.clientY - r.top) / r.height * 2 - 1))); }); |
| 163 | get('attention').onclick = () => interact('attention'); get('feed').onclick = () => interact('food'); |
| 164 | get('pause').onclick = () => { paused = !paused; get('pause').textContent = paused ? 'Play' : 'Pause'; get('pause').setAttribute('aria-pressed', String(paused)); silence(); if (audio) anchor = audio.currentTime - seconds + .08; last = 0; }; |
| 165 | get('sound').onclick = async () => { |
| 166 | try { if (!audio) audio = new AudioContext(); await audio.resume(); sound = !sound; silence(); anchor = audio.currentTime - seconds + .08; |
| 167 | get('sound').textContent = sound ? 'Sound on' : 'Sound off'; get('sound').setAttribute('aria-pressed', String(sound)); |
| 168 | } catch { message.textContent = 'Audio is unavailable in this browser. The visual replay remains available.'; } |
| 169 | }; |
| 170 | motion.onchange = () => rebuild(seconds, undefined, world.recording(false).start); seek.oninput = () => rebuild(Number(seek.value), undefined, world.recording(false).start); |
| 171 | mode.onchange = async () => { |
| 172 | if (!await mayLeave()) { mode.value = worldMode; return; } |
| 173 | stopFollowing(); |
| 174 | if (mode.value === 'replay' && imported) { |
| 175 | tape = imported.world.tape; interactions = [...imported.world.interactions]; source.textContent = imported.name; |
| 176 | seek.max = String(Math.max(90, imported.world.endTimeMs / 1000)); |
| 177 | ++generation; silence(); adoptWorld(imported.world); |
| 178 | message.textContent = 'Returned to the imported world at its current pose.'; return; |
| 179 | } |
| 180 | expressionVersion = 2; |
| 181 | tape = mode.value === 'demo' ? compilePetTelemetry(petDemoEvents(), 80_000) : []; |
| 182 | interactions = []; seek.max = mode.value === 'demo' ? '80' : '120'; |
| 183 | source.textContent = mode.value === 'demo' ? 'Event demo · synthetic telemetry' : 'Wild · simulated creature'; rebuild(); |
| 184 | message.textContent = mode.value === 'demo' ? 'Synthetic event-v1 telemetry uses the same derivation as imported traces.' : 'Wild mode is a simulated creature. Import event-v1, OTLP, or Codewhale telemetry to see work.'; |
| 185 | }; |
| 186 | get<HTMLInputElement>('file').onchange = async event => { |
| 187 | const input = event.target as HTMLInputElement, file = input.files?.[0]; if (!file) return; |
| 188 | try { |
| 189 | if (file.size > 64 * 1024 * 1024) throw new Error('Pet import exceeds 64 MiB.'); |
| 190 | const text = await file.text(); let replay: unknown; |
| 191 | try { replay = JSON.parse(text); } catch { /* JSONL and trace imports follow below. */ } |
| 192 | let next: PetWorld; |
| 193 | if (replay && typeof replay === 'object' && 'petReplayVersion' in replay) next = PetWorld.fromRecording(points, replay); |
| 194 | else { |
| 195 | let nextTape: readonly PetBucket[]; |
| 196 | const first = replay ?? JSON.parse(text.split(/\r?\n/).find(line => line.trim()) || '{}'); |
| 197 | if (first && typeof first === 'object' && 'version' in first && first.version === 1 && 'simTimeMs' in first) nextTape = decodePetJSONL(text); |
| 198 | else { |
| 199 | const traces = importTrace(text, file.name, { privacy: 'metadata' }); |
| 200 | if (traces.length !== 1) throw new Error('Choose one recording to import.'); |
| 201 | nextTape = compilePetTelemetry(traces[0].events, traces[0].duration); |
| 202 | } |
| 203 | next = new PetWorld(points, nextTape, [], 2, true); |
| 204 | } |
| 205 | if (!await mayLeave()) return; |
| 206 | adoptImported(next, `Local replay · ${file.name}`); |
| 207 | message.textContent = 'Recording loaded locally, including its current pose, interactions and score.'; |
| 208 | } catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to import this file.'; } |
| 209 | finally { input.value = ''; } |
| 210 | }; |
| 211 | get('save').onclick = () => { |
| 212 | try { |
| 213 | const chunks: string[] = []; let bytes = 0; |
| 214 | for (let index = 0; ; index++) { |
| 215 | const chunk = world.recordingChunk(index); if (chunk === null) break; |
| 216 | bytes += new TextEncoder().encode(chunk).length; |
| 217 | if (bytes > 64 * 1024 * 1024) throw new Error('Recording exceeds the 64 MiB export limit. The current world was kept.'); |
| 218 | chunks.push(chunk); |
| 219 | } |
| 220 | const blob = new Blob(chunks, { type: 'application/json' }); |
| 221 | const url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = 'codewhale-pet-replay.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); |
| 222 | } catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to export the recording. The current world was kept.'; } |
| 223 | }; |
| 224 | get('follow').onclick = async () => { |
| 225 | // A browser-granted read handle is the same local JSONL seam native hosts use. |
| 226 | // It is never persisted: reloading restores the accepted recording, not access. |
| 227 | const picker = (window as Window & { showOpenFilePicker?: () => Promise<{ getFile(): Promise<File> }[]> }).showOpenFilePicker; |
| 228 | if (!picker) { message.textContent = 'This browser cannot follow local files. Import a tape to replay it, or use the native pet for live telemetry.'; return; } |
| 229 | try { |
| 230 | const [handle] = await picker.call(window); if (!handle) return; |
| 231 | const file = await handle.getFile(); |
| 232 | if (!await mayLeave()) return; |
| 233 | stopFollowing(); const ticket = liveGeneration; |
| 234 | mode.value = 'live'; paused = false; get('pause').textContent = 'Pause'; get('pause').setAttribute('aria-pressed', 'false'); |
| 235 | get<HTMLButtonElement>('pause').disabled = true; seek.disabled = true; |
| 236 | expressionVersion = 2; tape = compilePetTelemetry([]); interactions = []; seek.max = '120'; |
| 237 | source.textContent = `Live local tape · ${file.name}`; await rebuild(); |
| 238 | const poll = async () => { |
| 239 | if (ticket !== liveGeneration) return; |
| 240 | if (document.hidden) { liveTimer = window.setTimeout(poll, 400); return; } |
| 241 | const observation = liveObservation; |
| 242 | try { |
| 243 | const current = await handle.getFile(); |
| 244 | const tail = await current.slice(Math.max(0, current.size - 262_144)).text(); |
| 245 | if (ticket !== liveGeneration) return; |
| 246 | if (restoring || document.hidden || observation !== liveObservation) { liveTimer = window.setTimeout(poll, 400); return; } |
| 247 | const packet = liveTape.readTail(tail); |
| 248 | if (packet) { world.acceptTelemetry(packet); tape = world.tape; } |
| 249 | message.textContent = 'Following local telemetry. Existing or unchanged input stays unobserved until new packets arrive.'; |
| 250 | } catch { if (ticket === liveGeneration && observation === liveObservation) { liveTape.reset(); message.textContent = 'Local tape unavailable or invalid · unobserved. Select the file again if it was replaced.'; } } |
| 251 | if (ticket === liveGeneration) liveTimer = window.setTimeout(poll, 400); |
| 252 | }; |
| 253 | await poll(); |
| 254 | } catch (error) { if (!(error instanceof DOMException && error.name === 'AbortError')) message.textContent = 'Unable to open this local tape.'; } |
| 255 | }; |
| 256 | document.addEventListener('visibilitychange', () => { |
| 257 | last = 0; silence(); |
| 258 | if (worldMode === 'live') { |
| 259 | liveObservation++; |
| 260 | liveTape.reset(); |
| 261 | if (!document.hidden && world && !restoring) { world.resumeObservation(); seconds = world.frame.timeMs / 1000; } |
| 262 | } |
| 263 | void persist(); if (audio) anchor = audio.currentTime - seconds + .08; |
| 264 | }); |
| 265 | window.addEventListener('pagehide', () => { void persist(); }); |
| 266 | function animate(now: number) { |
| 267 | if (world && !restoring && !paused && !document.hidden) { |
| 268 | accumulator += last ? Math.min(.1, (now - last) / 1000) : 0; |
| 269 | while (accumulator >= 1 / 30 && (mode.value === 'live' || !tape.length || seconds < duration())) { |
| 270 | world.step(1 / 30, { motion: !motion.checked, sensitivity: 1 }); seconds = world.frame.timeMs / 1000; play(world.voices); accumulator -= 1 / 30; |
| 271 | } |
| 272 | if ((mode.value === 'live' || !tape.length) && seconds >= duration()) seek.max = String(Math.ceil(seconds / 30) * 30 + 30); |
| 273 | draw(); |
| 274 | } |
| 275 | last = now; requestAnimationFrame(animate); |
| 276 | } |
| 277 | try { |
| 278 | const response = await fetch('./whale-points.tsv'); if (!response.ok) throw new Error('Whale point asset is unavailable.'); |
| 279 | points = (await response.text()).trim().split('\n').map(row => row.trim().split(/\s+/).map(Number) as [number, number]); |
| 280 | if (points.length !== 980 || points.some(p => p.length !== 2 || !p.every(Number.isFinite))) throw new Error('Invalid whale point asset.'); |
| 281 | try { |
| 282 | const saved = await library.getHabitat(); |
| 283 | if (saved) { |
| 284 | savedRevision = saved.revision; const h = saved.habitat; |
| 285 | if (h.petPersistenceVersion !== 1 || !Number.isFinite(h.seconds) || h.seconds < 0 || h.seconds > PET_MAX_SECONDS |
| 286 | || !['wild', 'demo', 'replay'].includes(h.source) || typeof h.still !== 'boolean' || typeof h.sourceName !== 'string') throw new Error('Saved habitat is invalid. Save a replay file before replacing it.'); |
| 287 | expressionVersion = h.expressionVersion === undefined ? 1 : h.expressionVersion; |
| 288 | if (![1, 2].includes(expressionVersion) || h.checkpoint && (h.checkpoint.sim?.expressionVersion ?? 1) !== expressionVersion) throw new Error('Saved expression version does not match its checkpoint.'); |
| 289 | const savedWorld = PetWorld.fromRecording(points, { ...h, petReplayVersion: h.petReplayVersion === undefined ? 1 : h.petReplayVersion }); |
| 290 | if (h.checkpoint && savedWorld.frame.timeMs / 1000 !== h.seconds) throw new Error('Saved pet clock does not match its checkpoint.'); |
| 291 | tape = h.tape; interactions = [...h.interactions]; mode.value = h.source; motion.checked = h.still || media.matches; |
| 292 | source.textContent = h.sourceName; |
| 293 | seek.max = String(Math.max(h.source === 'demo' ? 80 : h.source === 'replay' ? 90 : 120, savedWorld.endTimeMs / 1000)); |
| 294 | if (h.source === 'replay') { |
| 295 | mode.querySelector<HTMLOptionElement>('[value="replay"]')!.disabled = false; |
| 296 | } |
| 297 | message.textContent = 'Restoring the saved habitat…'; |
| 298 | if (h.checkpoint && h.still === motion.checked) adoptWorld(savedWorld); |
| 299 | else await rebuild(h.seconds, undefined, savedWorld.recording(false).start); |
| 300 | message.textContent = 'Habitat restored locally. Sound starts only when you enable it.'; |
| 301 | } else await rebuild(); |
| 302 | persistenceReady = true; void refreshArchives().catch(() => {}); |
| 303 | } catch (error) { persistenceFailed = true; get('persistence').textContent = error instanceof Error ? error.message : 'Local persistence is unavailable.'; await rebuild(); } |
| 304 | void refreshArchives().catch(() => {}); |
| 305 | setInterval(() => { void persist(); }, 5000); requestAnimationFrame(animate); |
| 306 | } catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to start the habitat.'; } |
| 307 | |
| 308 | // Joining is a view switch; existing IndexedDB recordings are retained. |
| 309 | get<HTMLInputElement>('join-shared').onchange = async event => { |
| 310 | const input = event.target as HTMLInputElement, file = input.files?.[0]; if (!file) return; |
| 311 | try { |
| 312 | if (file.size > 4096) throw new Error('Invalid local connection file.'); |
| 313 | const d = JSON.parse(await file.text()); |
| 314 | if (d.version !== 1 || !Number.isSafeInteger(d.port) || d.port < 1 || d.port > 65535 || !/^[a-f0-9]{64}$/i.test(d.token)) throw new Error('Invalid local connection file.'); |
| 315 | if (!await mayLeave()) return; |
| 316 | location.assign(`http://127.0.0.1:${d.port}/#${d.token}`); |
| 317 | } catch (e) { message.textContent = e instanceof Error ? e.message : 'Unable to attach.'; } |
| 318 | finally { input.value = ''; } |
| 319 | }; |
| 320 |