Files
ai.interactive.fiction/public/js/base-module.js
T

562 lines
18 KiB
JavaScript

/**
* Base Module Class
* Provides common functionality and enforces a consistent interface for all modules
*/
import { moduleRegistry } from './module-registry.js';
export class BaseModule {
constructor(id, name) {
this.id = id;
this.name = name;
this.state = 'PENDING';
this.progress = 0;
this.progressCallback = null;
// Add standard event target for custom events
this.eventTarget = document.createElement('div');
// Add standard configuration object
this.config = {};
// Track event listeners for cleanup
this._eventListeners = [];
// Resource loading tracking
this._loadingResources = new Map();
this._totalResources = 0;
this._loadedResources = 0;
// Dependencies
this.dependencies = [];
this._loadedDependencies = new Map();
// Auto-register with module registry
moduleRegistry.register(this);
}
/**
* Initialize the module interface
* @param {Function} progressCallback - Function to report progress
* @returns {Promise} - Resolves when initialization is complete
*/
async initializeInterface(progressCallback) {
this.progressCallback = progressCallback;
try {
this.changeState('LOADING');
this.reportProgress(10, "Starting initialization");
// Skip loadDependencies() call - now handled automatically
// Wait for dependencies
const depStatus = await this._waitForModuleDependencies();
if (!depStatus) {
// If dependencies aren't available, report waiting
this.changeState('WAITING');
return Promise.resolve(false);
}
this.changeState('INITIALIZING');
const initResult = await this.initialize();
if (initResult) {
this.changeState('FINISHED');
this.reportProgress(100, "Initialization complete");
} else {
this.changeState('ERROR');
this.reportProgress(100, "Initialization failed");
}
return initResult;
} catch (error) {
console.error(`Error in module ${this.id}:`, error);
this.changeState('ERROR');
this.reportProgress(100, "Error during initialization");
return Promise.resolve(false);
}
}
/**
* Wait for module dependencies
* @returns {Promise<boolean>} - Resolves when dependencies are ready
*/
async _waitForModuleDependencies() {
if (!this.dependencies || this.dependencies.length === 0) {
return true;
}
try {
this.reportProgress(15, "Waiting for dependencies");
// Get moduleRegistry - first try import then fallback to window
const registry = moduleRegistry;
return this._continueWaitForDependencies(registry);
} catch (error) {
console.error(`${this.id}: Error waiting for dependencies:`, error);
return false;
}
}
/**
* Continue waiting for dependencies using the provided registry
* @param {ModuleRegistry} registry - The module registry
* @returns {Promise<boolean>} - Resolves when dependencies are ready
* @private
*/
async _continueWaitForDependencies(registry) {
try {
// Wait for all dependencies to be ready
const results = await registry.waitForModules(this.dependencies);
// Store references to dependencies
let hasErroredDependencies = false;
for (let i = 0; i < this.dependencies.length; i++) {
const depId = this.dependencies[i];
const depModule = registry.getModule(depId);
if (depModule) {
this._loadedDependencies.set(depId, depModule);
// Check if this dependency is in ERROR state
if (depModule.state === 'ERROR') {
hasErroredDependencies = true;
console.warn(`${this.id}: Dependency ${depId} is in ERROR state but will be considered resolved`);
}
}
}
// Check if all dependencies have resolved (either success or error)
// We consider a module with ERROR state as resolved
const allDepsResolved = results.every(result => result === true || result === false);
if (allDepsResolved) {
if (hasErroredDependencies) {
this.reportProgress(20, "Dependencies resolved with some errors");
} else {
this.reportProgress(20, "Dependencies ready");
}
return true;
} else {
this.reportProgress(15, "Some dependencies not ready");
return false;
}
} catch (error) {
console.error(`${this.id}: Error in _continueWaitForDependencies:`, error);
return false;
}
}
/**
* Initialize the module - Override this in child classes
* @returns {Promise<boolean>} - Resolves when initialization is complete
*/
async initialize() {
return Promise.resolve(true);
}
/**
* Change the module state and dispatch an event
* @param {string} state - The new state
*/
changeState(state) {
this.state = state;
document.dispatchEvent(new ModuleEvent('stateChange', this.id, { state }));
}
/**
* Report progress to the module loader
* @param {number} percent - Progress percentage (0-100)
* @param {string} message - Status message
*/
reportProgress(percent, message) {
this.progress = Math.min(100, Math.max(0, percent));
if (this.progressCallback && typeof this.progressCallback === 'function') {
this.progressCallback(percent, message);
} else {
document.dispatchEvent(new ModuleEvent('progress', this.id, { progress: percent }));
if (message) {
document.dispatchEvent(new ModuleEvent('message', this.id, { message }));
}
}
}
/**
* Get the current module state
* @returns {string} - Current state
*/
getState() {
return this.state;
}
/**
* Dispatch a module event
* @param {string} name - Event name
* @param {Object} detail - Event details
*/
dispatchEvent(name, detail = {}) {
const event = new CustomEvent(name, {
detail: { moduleId: this.id, ...detail },
bubbles: true
});
document.dispatchEvent(event);
return event;
}
/**
* Add an event listener with automatic tracking for cleanup
* @param {EventTarget} target - Event target (document, window, etc)
* @param {string} type - Event type
* @param {Function} listener - Event listener
* @param {Object} options - Event listener options
*/
addEventListener(target, type, listener, options = {}) {
target.addEventListener(type, listener, options);
this._eventListeners.push({ target, type, listener, options });
}
/**
* Remove a specific event listener
* @param {EventTarget} target - Event target
* @param {string} type - Event type
* @param {Function} listener - Event listener
* @param {Object} options - Event listener options
*/
removeEventListener(target, type, listener, options = {}) {
target.removeEventListener(type, listener, options);
this._eventListeners = this._eventListeners.filter(
item => !(item.target === target &&
item.type === type &&
item.listener === listener)
);
}
/**
* Remove all event listeners registered through addEventListener
*/
removeAllEventListeners() {
this._eventListeners.forEach(({ target, type, listener, options }) => {
target.removeEventListener(type, listener, options);
});
this._eventListeners = [];
}
/**
* Get a reference to another module
* @param {string} moduleId - ID of the module to get
* @returns {BaseModule|null} - The module or null if not found
*/
getModule(moduleId) {
// First check our dependency cache
if (this._loadedDependencies.has(moduleId)) {
return this._loadedDependencies.get(moduleId);
}
}
/**
* Auto-bind methods to preserve 'this' context
* @param {Array<string>} methodNames - Array of method names to bind
*/
bindMethods(methodNames) {
methodNames.forEach(methodName => {
if (typeof this[methodName] === 'function') {
this[methodName] = this[methodName].bind(this);
} else {
console.warn(`Method ${methodName} not found on ${this.id} module.`);
}
});
}
/**
* Update configuration
* @param {Object} newConfig - New configuration to merge
*/
updateConfig(newConfig = {}) {
this.config = { ...this.config, ...newConfig };
}
/**
* Get current configuration
* @returns {Object} - Current configuration
*/
getConfig() {
return { ...this.config };
}
/**
* Load a JavaScript file
* @param {string} url - URL of the script to load
* @param {boolean} [isModule=false] - Whether to load as a module
* @returns {Promise<HTMLScriptElement>} - Promise resolving to the loaded script element
*/
loadScript(url, isModule = false) {
return new Promise((resolve, reject) => {
// Track this resource
this._trackResource(url);
const script = document.createElement('script');
script.src = url;
if (isModule) {
script.type = 'module';
}
script.onload = () => {
this._resourceLoaded(url);
resolve(script);
};
script.onerror = (error) => {
this._resourceFailed(url, error);
reject(new Error(`Failed to load script: ${url}`));
};
document.head.appendChild(script);
});
}
/**
* Load a CSS stylesheet
* @param {string} url - URL of the stylesheet to load
* @returns {Promise<HTMLLinkElement>} - Promise resolving to the loaded link element
*/
loadCSS(url) {
return new Promise((resolve, reject) => {
// Track this resource
this._trackResource(url);
const link = document.createElement('link');
link.href = url;
link.rel = 'stylesheet';
link.onload = () => {
this._resourceLoaded(url);
resolve(link);
};
link.onerror = (error) => {
this._resourceFailed(url, error);
reject(new Error(`Failed to load stylesheet: ${url}`));
};
document.head.appendChild(link);
});
}
/**
* Preload an image
* @param {string} url - URL of the image to load
* @returns {Promise<HTMLImageElement>} - Promise resolving to the loaded image element
*/
loadImage(url) {
return new Promise((resolve, reject) => {
// Track this resource
this._trackResource(url);
const img = new Image();
img.onload = () => {
this._resourceLoaded(url);
resolve(img);
};
img.onerror = (error) => {
this._resourceFailed(url, error);
reject(new Error(`Failed to load image: ${url}`));
};
img.src = url;
});
}
/**
* Load JSON data
* @param {string} url - URL of the JSON file to load
* @returns {Promise<Object>} - Promise resolving to the parsed JSON data
*/
loadJSON(url) {
return new Promise((resolve, reject) => {
// Track this resource
this._trackResource(url);
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
return response.json();
})
.then(data => {
this._resourceLoaded(url);
resolve(data);
})
.catch(error => {
this._resourceFailed(url, error);
reject(new Error(`Failed to load JSON: ${url} - ${error.message}`));
});
});
}
/**
* Load a generic resource with fetch
* @param {string} url - URL of the resource to load
* @param {string} [responseType='text'] - Response type ('text', 'blob', 'arrayBuffer', etc.)
* @returns {Promise<any>} - Promise resolving to the loaded resource
*/
loadResource(url, responseType = 'text') {
return new Promise((resolve, reject) => {
// Track this resource
this._trackResource(url);
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
switch(responseType) {
case 'json': return response.json();
case 'blob': return response.blob();
case 'arrayBuffer': return response.arrayBuffer();
case 'formData': return response.formData();
case 'text':
default: return response.text();
}
})
.then(data => {
this._resourceLoaded(url);
resolve(data);
})
.catch(error => {
this._resourceFailed(url, error);
reject(new Error(`Failed to load resource: ${url} - ${error.message}`));
});
});
}
/**
* Load multiple resources at once
* @param {Array<Object>} resources - Array of resource descriptors
* @returns {Promise<Array>} - Promise resolving to an array of loaded resources
* @example
* loadResources([
* { type: 'script', url: '/js/lib.js' },
* { type: 'css', url: '/css/style.css' },
* { type: 'image', url: '/img/logo.png' },
* { type: 'json', url: '/data/config.json' }
* ])
*/
loadResources(resources) {
const promises = resources.map(resource => {
switch(resource.type) {
case 'script':
return this.loadScript(resource.url, resource.isModule);
case 'css':
return this.loadCSS(resource.url);
case 'image':
return this.loadImage(resource.url);
case 'json':
return this.loadJSON(resource.url);
default:
return this.loadResource(resource.url, resource.responseType);
}
});
return Promise.all(promises);
}
/**
* Track a resource being loaded
* @param {string} url - URL of the resource
* @private
*/
_trackResource(url) {
this._loadingResources.set(url, {
started: Date.now(),
completed: false,
failed: false
});
this._totalResources++;
// Report progress
this._updateResourceProgress();
}
/**
* Mark a resource as successfully loaded
* @param {string} url - URL of the resource
* @private
*/
_resourceLoaded(url) {
if (this._loadingResources.has(url)) {
const resource = this._loadingResources.get(url);
resource.completed = true;
resource.completedAt = Date.now();
this._loadedResources++;
// Report progress
this._updateResourceProgress();
}
}
/**
* Mark a resource as failed to load
* @param {string} url - URL of the resource
* @param {Error} error - Error that occurred
* @private
*/
_resourceFailed(url, error) {
if (this._loadingResources.has(url)) {
const resource = this._loadingResources.get(url);
resource.failed = true;
resource.error = error;
resource.completedAt = Date.now();
this._loadedResources++;
// Log the error
console.error(`${this.id}: Failed to load resource:`, url, error);
// Report progress
this._updateResourceProgress();
}
}
/**
* Update loading progress based on resources
* @private
*/
_updateResourceProgress() {
if (this._totalResources === 0) return;
// Change to FETCHING state when loading resources
if (this.state === 'LOADING' && this._loadedResources === 0) {
this.changeState('FETCHING');
}
// Scale resource loading to 10% to 50% range of total module progress
const percent = Math.round((this._loadedResources / this._totalResources) * 40) + 10;
this.reportProgress(percent, `Loading resources: ${this._loadedResources}/${this._totalResources}`);
}
/**
* Dispose resources when module is destroyed
* Override in child classes to add custom cleanup
*/
dispose() {
this.removeAllEventListeners();
}
}
/**
* Module Events - Used for communication between modules and the loader
*/
export class ModuleEvent extends CustomEvent {
constructor(type, moduleId, data = {}) {
super(`module:${type}`, {
detail: {
moduleId,
...data
},
bubbles: true
});
}
}