Files
ai.interactive.fiction/public/js/persistence-manager-module.js

749 lines
25 KiB
JavaScript

/**
* 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<boolean>} - 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 };