/** * Persistence Manager Module * Handles saving and loading game state and user preferences */ import { BaseModule } from './base-module.js'; class PersistenceManagerModule extends BaseModule { /** * Create a new persistence manager */ constructor() { super('persistence-manager', 'Persistence Manager'); // Storage keys this.keys = { gameState: 'ai-interactive-fiction-state', preferences: 'ai-interactive-fiction-preferences', saveSlots: 'ai-interactive-fiction-saves' }; // Current game state this.gameState = null; // User preferences this.preferences = null; // Save slots this.saveSlots = {}; // Default preferences this.defaultPreferences = { tts: { enabled: false, preferred_handler: 'none', speed: 1.0, language: 'en_US', voice: '', 'browser-tts_timeout_ms': 60000, 'kokoro-tts_timeout_ms': 60000, 'elevenlabs-tts_api_key': '', 'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1', 'elevenlabs-tts_timeout_ms': 60000, 'openai-tts_api_key': '', 'openai-tts_api_url': 'https://api.openai.com/v1', 'openai-tts_model': 'tts-1-hd', 'openai-tts_timeout_ms': 60000, 'local-openai-tts_api_key': '', 'local-openai-tts_api_url': 'http://localhost:8000/v1', 'local-openai-tts_voice': 'alloy', 'local-openai-tts_model': 'tts-1', 'local-openai-tts_timeout_ms': 60000 }, audio: { masterVolume: 1.0, masterVolumeEnabled: true, ttsVolume: 1.0, ttsVolumeEnabled: true, musicVolume: 0.7, musicVolumeEnabled: true, sfxVolume: 1.0, sfxVolumeEnabled: true, musicDuckingAmount: 0.3, musicDuckingEnabled: true, }, app: { locale: null, localeUserOverride: false, speed: 1.0, autoplay: true, }, webgl: { mode: null, bookPageCount: 300, bookProgress: 0, pageReserve: 50 } }; // Bind methods this.bindMethods([ 'saveGameState', 'loadGameState', 'savePreferences', 'loadPreferences', 'getPreference', 'updatePreference', 'resetPreferences', 'createSaveSlot', 'loadSaveSlot', 'deleteSaveSlot', 'getAllSaveSlots', 'createBinding', 'updateElementFromPreference', 'updatePreferenceFromElement', 'setupBindings' ]); // Remove circular dependency this.dependencies = []; } /** * Initialize the module * @returns {Promise} - Resolves with success status */ async initialize() { try { this.reportProgress(10, "Initializing persistence manager"); // Load preferences first (with default language settings) this.loadPreferences(); // Load save slots this.loadSaveSlots(); this.reportProgress(100, "Persistence manager ready"); return true; } catch (error) { console.error("Error initializing persistence manager:", error); this.reportProgress(100, "Persistence manager failed"); return false; } } /** * Save the current game state * @param {Object} state - Game state to save * @returns {boolean} - Success status */ saveGameState(state) { if (!state) return false; try { this.gameState = state; localStorage.setItem(this.keys.gameState, JSON.stringify(state)); // Dispatch event this.dispatchEvent('game-state-saved', { timestamp: new Date().toISOString() }); return true; } catch (error) { console.error("Error saving game state:", error); return false; } } /** * Load the current game state * @returns {Object|null} - Loaded game state or null if not found */ loadGameState() { try { const stateJson = localStorage.getItem(this.keys.gameState); if (!stateJson) return null; this.gameState = JSON.parse(stateJson); return this.gameState; } catch (error) { console.error("Error loading game state:", error); return null; } } /** * Save user preferences * @returns {boolean} - Success status */ savePreferences() { if (!this.preferences) return false; try { localStorage.setItem(this.keys.preferences, JSON.stringify(this.preferences)); // Dispatch event this.dispatchEvent('preferences-saved', { timestamp: new Date().toISOString() }); return true; } catch (error) { console.error("Error saving preferences:", error); return false; } } /** * Load user preferences * @returns {Object} - Loaded preferences or default preferences if not found */ loadPreferences() { try { const prefsJson = localStorage.getItem(this.keys.preferences); if (prefsJson) { // Parse stored preferences const storedPrefs = JSON.parse(prefsJson); // Merge with defaults to ensure all keys exist this.preferences = this.mergeWithDefaults(storedPrefs, this.defaultPreferences); } else { // Use defaults if no stored preferences found this.preferences = {...this.defaultPreferences}; } return this.preferences; } catch (error) { console.error("Error loading preferences:", error); this.preferences = {...this.defaultPreferences}; return this.preferences; } } /** * Merge stored preferences with defaults to ensure all keys exist * @param {Object} stored - Stored preferences * @param {Object} defaults - Default preferences * @returns {Object} - Merged preferences */ mergeWithDefaults(stored, defaults) { // Base case: if stored is not an object or is null, return defaults if (typeof stored !== 'object' || stored === null) { return defaults; } // Create a new object to avoid modifying the input objects const merged = {}; // Add all keys from defaults, overriding with stored values where they exist for (const key in defaults) { if (Object.prototype.hasOwnProperty.call(defaults, key)) { // If the default value is an object and not null, recurse if (typeof defaults[key] === 'object' && defaults[key] !== null) { merged[key] = this.mergeWithDefaults( Object.prototype.hasOwnProperty.call(stored, key) ? stored[key] : {}, defaults[key] ); } else { // Otherwise, use stored value if it exists, otherwise use default merged[key] = Object.prototype.hasOwnProperty.call(stored, key) ? stored[key] : defaults[key]; } } } return merged; } /** * Get a specific preference * @param {string} category - Preference category * @param {string} setting - Preference setting * @param {*} defaultValue - Default value if preference not found * @returns {*} - Preference value */ getPreference(category, setting, defaultValue = null) { if (!category || !setting) return defaultValue; // Ensure preferences are loaded if (!this.preferences) { this.loadPreferences(); } // Check if category exists if (!this.preferences[category]) return defaultValue; // Check if setting exists in category if (!Object.prototype.hasOwnProperty.call(this.preferences[category], setting)) { return defaultValue; } return this.preferences[category][setting]; } /** * Update a specific preference * @param {string} category - Preference category * @param {string} setting - Preference setting * @param {*} value - New value * @returns {boolean} - Success status */ updatePreference(category, setting, value) { if (!this.preferences) return false; // Ensure category exists if (!this.preferences[category]) { this.preferences[category] = {}; } if (Object.prototype.hasOwnProperty.call(this.preferences[category], setting) && Object.is(this.preferences[category][setting], value)) { return true; } // Store value this.preferences[category][setting] = value; // Save preferences const success = this.savePreferences(); console.log("Saved preferences: ", category, setting, value, this.preferences) // Dispatch event this.dispatchEvent('preference-updated', { category, key: setting, value, timestamp: new Date().toISOString() }); return success; } /** * Reset preferences to defaults * @returns {boolean} - Success status */ resetPreferences() { try { // Create a deep clone of default preferences this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences)); // Save preferences const success = this.savePreferences(); // Dispatch event this.dispatchEvent('preferences-reset', { timestamp: new Date().toISOString() }); return success; } catch (error) { console.error("Error resetting preferences:", error); return false; } } /** * Get all preferences * @returns {Object} - All preferences */ getAllPreferences() { if (!this.preferences) { this.loadPreferences(); } return this.preferences; } /** * Load save slots * @returns {Object} - Save slots */ loadSaveSlots() { try { const slotsJson = localStorage.getItem(this.keys.saveSlots); if (slotsJson) { this.saveSlots = JSON.parse(slotsJson); // Validate each save slot for (const id in this.saveSlots) { const slot = this.saveSlots[id]; if (!slot.id || !slot.name || !slot.timestamp || !slot.state) { delete this.saveSlots[id]; } } } else { this.saveSlots = {}; } return this.saveSlots; } catch (error) { console.error("Error loading save slots:", error); this.saveSlots = {}; return this.saveSlots; } } /** * Save save slots * @returns {boolean} - Success status */ saveSaveSlots() { try { localStorage.setItem(this.keys.saveSlots, JSON.stringify(this.saveSlots)); return true; } catch (error) { console.error("Error saving save slots:", error); return false; } } /** * Create a new save slot * @param {string} name - Save slot name * @param {Object} state - Game state to save * @returns {string|null} - Save slot ID or null if failed */ createSaveSlot(name, state) { if (!name || !state) return null; try { // Generate unique ID const id = `save_${Date.now()}`; // Create save slot this.saveSlots[id] = { id, name, timestamp: new Date().toISOString(), state }; // Save save slots this.saveSaveSlots(); // Dispatch event this.dispatchEvent('save-slot-created', { id, name, timestamp: new Date().toISOString() }); return id; } catch (error) { console.error("Error creating save slot:", error); return null; } } /** * Load a save slot * @param {string} id - Save slot ID * @returns {Object|null} - Game state or null if not found */ loadSaveSlot(id) { if (!id || !this.saveSlots[id]) return null; try { const saveSlot = this.saveSlots[id]; // Set as current game state this.gameState = saveSlot.state; // Save current game state this.saveGameState(this.gameState); // Dispatch event this.dispatchEvent('save-slot-loaded', { id, name: saveSlot.name, timestamp: new Date().toISOString() }); return this.gameState; } catch (error) { console.error("Error loading save slot:", error); return null; } } /** * Delete a save slot * @param {string} id - Save slot ID * @returns {boolean} - Success status */ deleteSaveSlot(id) { if (!id || !this.saveSlots[id]) return false; try { // Get save slot name before deleting const name = this.saveSlots[id].name; // Delete save slot delete this.saveSlots[id]; // Save save slots this.saveSaveSlots(); // Dispatch event this.dispatchEvent('save-slot-deleted', { id, name, timestamp: new Date().toISOString() }); return true; } catch (error) { console.error("Error deleting save slot:", error); return false; } } /** * Get all save slots * @returns {Object} - All save slots */ getAllSaveSlots() { if (!this.saveSlots) { this.loadSaveSlots(); } return this.saveSlots; } /** * Create a binding between a DOM element and a preference * @param {HTMLElement} element - Element to bind to * @param {string} category - Preference category * @param {string} key - Preference key * @param {Object} options - Additional options (transformers, etc) * @returns {Object} - Binding control object */ createBinding(element, category, key, options = {}) { if (!element) return null; const transformer = options.transformer || { toElement: (value) => value, toPreference: (value) => value }; // Store binding info on the element element._prefBinding = { category, key, transformer }; // Set initial value this.updateElementFromPreference(element); // Set up event listeners const eventHandler = () => this.updatePreferenceFromElement(element); // Choose appropriate events based on element type let events = ['change']; if (element.type !== 'checkbox' && element.type !== 'radio' && element.tagName !== 'SELECT') { events.push('input'); } // Attach event listeners events.forEach(event => { element.addEventListener(event, eventHandler); }); // Return control object return { update: () => this.updateElementFromPreference(element), destroy: () => { events.forEach(event => { element.removeEventListener(event, eventHandler); }); delete element._prefBinding; } }; } /** * Update an element value from its bound preference * @param {HTMLElement} element - The bound element */ updateElementFromPreference(element) { if (!element || !element._prefBinding) return; const { category, key, transformer } = element._prefBinding; const value = this.getPreference(category, key); const transformedValue = transformer.toElement(value); // Set element value based on its type if (element.type === 'checkbox') { element.checked = !!transformedValue; } else if (element.type === 'radio') { element.checked = element.value === String(transformedValue); } else if (element.tagName === 'SELECT') { element.value = transformedValue; } else { element.value = transformedValue; } } /** * Update a preference from its bound element * @param {HTMLElement} element - The bound element */ updatePreferenceFromElement(element) { if (!element || !element._prefBinding) return; const { category, key, transformer } = element._prefBinding; let value; // Get element value based on its type if (element.type === 'checkbox') { value = element.checked; } else if (element.type === 'radio') { value = element.checked ? element.value : null; } else { value = element.value; } const transformedValue = transformer.toPreference(value); this.updatePreference(category, key, transformedValue); } /** * Set up bindings for all elements with data-pref-bind attributes * @param {string} rootSelector - Root selector to search within * @returns {Array} - Array of binding control objects */ setupBindings(rootSelector = 'body') { const root = document.querySelector(rootSelector); if (!root) return []; const bindings = []; const elements = root.querySelectorAll('[data-pref-bind]'); elements.forEach(element => { const bindingStr = element.dataset.prefBind; if (!bindingStr) return; const [category, key] = bindingStr.split('.'); if (!category || !key) return; // Parse transformer if specified let transformer = { toElement: (value) => value, toPreference: (value) => value }; // Handle range transformations if (element.type === 'range' && element.hasAttribute('min') && element.hasAttribute('max')) { const min = parseInt(element.getAttribute('min'), 10) || 0; const max = parseInt(element.getAttribute('max'), 10) || 100; transformer = { toElement: (value) => { // Convert from 0-1 to min-max return Math.round(value * (max - min) + min); }, toPreference: (value) => { // Convert from min-max to 0-1 return (parseInt(value, 10) - min) / (max - min); } }; } // Custom transformer via data attribute if (element.dataset.prefTransform) { try { // Check if it's a range transformer in format 'range:min,max' if (element.dataset.prefTransform === 'centered-speed') { transformer = { toElement: (value) => Math.round(Math.max(0.5, Math.min(2.0, Number(value) || 1)) * 100), toPreference: (value) => { const percent = parseInt(value, 10); return Math.max(0.5, Math.min(2.0, (Number.isFinite(percent) ? percent : 100) / 100)); } }; } else if (element.dataset.prefTransform === 'multiplier-percent') { transformer = { toElement: (value) => Math.round(Math.max(0.5, Math.min(2.0, Number(value) || 1)) * 100), toPreference: (value) => { const percent = parseInt(value, 10); return Math.max(0.5, Math.min(2.0, (Number.isFinite(percent) ? percent : 100) / 100)); } }; } else if (element.dataset.prefTransform.startsWith('integer:')) { const rangeValues = element.dataset.prefTransform.substring(8).split(','); const min = Number.parseInt(rangeValues[0], 10); const max = Number.parseInt(rangeValues[1], 10); transformer = { toElement: (value) => Number.parseInt(value, 10), toPreference: (value) => { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) { return Number.isFinite(min) ? min : 0; } if (Number.isFinite(min) && parsed < min) { return min; } if (Number.isFinite(max) && parsed > max) { return max; } return parsed; } }; } else if (element.dataset.prefTransform.startsWith('range:')) { const rangeValues = element.dataset.prefTransform.substring(6).split(','); if (rangeValues.length === 2) { const min = parseFloat(rangeValues[0]); const max = parseFloat(rangeValues[1]); if (!isNaN(min) && !isNaN(max)) { transformer = { toElement: (value) => { // Convert from min-max to 0-100 for the slider return Math.round(((value - min) / (max - min)) * 100); }, toPreference: (value) => { // Convert from 0-100 to min-max return min + (parseInt(value, 10) / 100) * (max - min); } }; } } } else { const customTransformer = JSON.parse(element.dataset.prefTransform); if (customTransformer && typeof customTransformer === 'object') { transformer = customTransformer; } } } catch (e) { console.warn('Invalid transformer data attribute', e); } } const binding = this.createBinding(element, category, key, { transformer }); if (binding) { bindings.push(binding); } }); // Set up event listener for preference changes from other sources document.addEventListener('preference-updated', (event) => { const { category, key } = event.detail; // Update any matching elements elements.forEach(element => { if (!element._prefBinding) return; if (element._prefBinding.category === category && element._prefBinding.key === key) { this.updateElementFromPreference(element); } }); }); return bindings; } /** * Clean up when module is disposed */ dispose() { // Nothing to clean up } } // Create the singleton instance const PersistenceManager = new PersistenceManagerModule(); // Export the module export { PersistenceManager };