fuzzyworld

dm / commit

feat: persist adventure session across page reloads

5b1b771cac9ae8639befe3c67b13df1c9a2220b5 · Pavel Safronov · 2026-05-27 17:35:03 -0700 · parents 3b95ac8d683b

feat: persist adventure session across page reloads

Saves conversation history, character state, and rendered messages to
localStorage after every completed turn. On page load, resumes the
last session automatically if an API key is also saved. "New Game"
and "Forget saved key" both clear the session. Shared-link URLs
(#k=...) skip resume entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 public/index.html | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 58 insertions(+), 1 deletion(-)

diff --git a/public/index.html b/public/index.html
index f9b87b0..a897a67 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1267,6 +1267,55 @@ let gameState = null;
 let conversationHistory = [];
 let isBusy = false;
 
+// ── SESSION PERSISTENCE ───────────────────────────────
+
+function saveSession() {
+  if (!gameState) return;
+  const { apiKey: _, ...stateToSave } = gameState;
+  try {
+    localStorage.setItem('dm_session', JSON.stringify({
+      gameState: stateToSave,
+      conversationHistory,
+      messagesHTML: document.getElementById('messages').innerHTML,
+    }));
+  } catch (e) {
+    console.warn('Session save failed (storage full?):', e);
+  }
+}
+
+async function tryResumeSession() {
+  if (getSharedBlob()) return false;
+  const sessionRaw = localStorage.getItem('dm_session');
+  if (!sessionRaw) return false;
+  const keyRaw = localStorage.getItem('dm_api_key');
+  if (!keyRaw) return false;
+
+  let session, apiKey;
+  try {
+    session = JSON.parse(sessionRaw);
+    apiKey = await decryptFromStorage(keyRaw);
+    if (!apiKey) return false;
+  } catch { return false; }
+
+  gameState = { ...session.gameState, apiKey };
+  conversationHistory = session.conversationHistory || [];
+
+  document.getElementById('creation-screen').style.display = 'none';
+  document.getElementById('game-screen').classList.add('visible');
+  document.getElementById('messages').innerHTML = session.messagesHTML || '';
+  document.getElementById('header-title').textContent =
+    `${gameState.character.name} — ${gameState.character.race} ${gameState.character.class}`;
+
+  updateSidebar();
+  buildDiceRoller();
+
+  const msgEl = document.getElementById('messages');
+  msgEl.scrollTop = msgEl.scrollHeight;
+
+  console.log(`▶ Resumed session — ${conversationHistory.length} messages, Lv ${gameState.character.level} ${gameState.character.name}`);
+  return true;
+}
+
 // ── CREATION SCREEN ───────────────────────────────────
 
 async function buildCreationScreen() {
@@ -1326,6 +1375,7 @@ async function buildCreationScreen() {
 
     document.getElementById('forget-key-btn').addEventListener('click', () => {
       localStorage.removeItem('dm_api_key');
+      localStorage.removeItem('dm_session');
       document.getElementById('api-key-input').value = '';
       document.getElementById('key-saved-badge').style.display = 'none';
       document.getElementById('forget-key-btn').style.display = 'none';
@@ -1848,6 +1898,7 @@ async function sendMessage(text, showAsPlayer = true) {
     finalizeDMMessage(dmEl, rawText);
     if (statUpdate) applyStatUpdate(statUpdate);
     conversationHistory.push({ role: 'assistant', content: rawText });
+    saveSession();
     console.log(`✅ Turn complete. History length: ${conversationHistory.length}`);
 
   } catch (err) {
@@ -1920,12 +1971,18 @@ document.getElementById('new-game-btn').addEventListener('click', () => {
   conversationHistory = [];
   isBusy = false;
   document.querySelectorAll('.sel-card').forEach(c => c.classList.remove('selected'));
+  localStorage.removeItem('dm_session');
   checkBeginReady();
 });
 
 // ── INIT ──────────────────────────────────────────────
 
-buildCreationScreen().catch(console.error);
+(async () => {
+  // Always build the creation screen so grids/listeners are ready for "New Game"
+  await buildCreationScreen();
+  // Attempt resume — overwrites the game/creation screen state if a session exists
+  await tryResumeSession();
+})().catch(console.error);
 </script>
 </body>
 </html>