/** * action_dispatcher.js * Implements the restricted behavior vocabulary for mapping declarative action * type strings to engine operations. */ export const VALID_ACTION_TYPES = new Set([ 'desktop.notify', 'desktop.openApp', 'desktop.focusApp', 'desktop.setBadge', 'desktop.endScenario', 'mail.deliver', 'mail.updateMessage', 'navigator.open', 'navigator.redirect', 'files.create', 'files.open', 'security.addAlert', 'security.updateStatus', 'evaluation.addFinding', 'evaluation.setState', 'evaluation.completeObjective', 'evaluation.awardPoints', 'evaluation.revealFeedback' ]); /** * ActionDispatcher handles declarative actions triggered by events, * mapping them to actual system operations within the simulation. */ export class ActionDispatcher { /** * Create an ActionDispatcher. * @param {Object} deps - Dependencies. * @param {Object} deps.scenarioState - The scenario state manager. * @param {Object} deps.scenario - The current scenario definition. * @param {Object} deps.eventBus - The global event bus. * @param {Object} deps.notifications - The desktop notification manager. * @param {Object} [deps.apps] - The initialized applications (can be registered later). */ constructor({ scenarioState, scenario, eventBus, notifications, apps = {} }) { this.scenarioState = scenarioState; this.scenario = scenario; this.eventBus = eventBus; this.notifications = notifications; this.apps = apps; this.onEndScenario = null; } /** * Register or update the application references. * @param {Object} apps - Object containing app instances (e.g., inlook, navigator, files, docViewer, securityCenter). */ registerApps(apps) { this.apps = { ...this.apps, ...apps }; } /** * Register a callback to be called when the scenario ends. * @param {Function} callback - The callback function. */ registerEndScenarioCallback(callback) { this.onEndScenario = callback; } /** * Dispatch an action by its declarative type. * @param {Object} action - The action object containing a 'type' and properties. */ dispatch(action) { if (!action || typeof action.type !== 'string') { console.error('ActionDispatcher: Invalid action object', action); return; } if (!VALID_ACTION_TYPES.has(action.type)) { console.error(`ActionDispatcher: Unknown action type rejected: ${action.type}`); return; } try { this._handleAction(action); this.eventBus.emit('ACTION_DISPATCHED', { type: action.type, action }); } catch (error) { console.error(`ActionDispatcher: Error dispatching action ${action.type}:`, error, action); } } /** * Internal handler to process the action based on its type. * @private * @param {Object} action - The action object. */ _handleAction(action) { const context = this._buildInterpolationContext(); // Interpolate string properties recursively const interpolatedAction = this._interpolateProperties(action, context); switch (interpolatedAction.type) { // Desktop actions case 'desktop.notify': this._handleDesktopNotify(interpolatedAction); break; case 'desktop.openApp': if (this.apps[interpolatedAction.app] && typeof this.apps[interpolatedAction.app].launch === 'function') { this.apps[interpolatedAction.app].launch(); } else { console.warn(`ActionDispatcher: App ${interpolatedAction.app} not found or has no launch method.`); } break; case 'desktop.focusApp': // The desktop window manager should listen to this event this.eventBus.emit('DESKTOP_FOCUS_APP', { app: interpolatedAction.app }); break; case 'desktop.setBadge': // The desktop component/taskbar should listen to this event this.eventBus.emit('DESKTOP_SET_BADGE', { app: interpolatedAction.app, count: interpolatedAction.count }); break; case 'desktop.endScenario': if (typeof this.onEndScenario === 'function') { this.onEndScenario(); } break; // Mail actions case 'mail.deliver': { const messageId = interpolatedAction.message; const messageObj = this.scenario.messages?.find(m => m.id === messageId); if (!messageObj) { throw new Error(`Message ID '${messageId}' not found in scenario.messages`); } if (this.scenarioState && typeof this.scenarioState.deliverMessage === 'function') { this.scenarioState.deliverMessage(messageId); } if (this.apps.inlook && typeof this.apps.inlook.deliverMessage === 'function') { this.apps.inlook.deliverMessage(messageObj); } break; } case 'mail.updateMessage': if (this.apps.inlook && typeof this.apps.inlook.updateMessage === 'function') { this.apps.inlook.updateMessage(interpolatedAction.message, interpolatedAction.updates); } break; // Navigator actions case 'navigator.open': if (this.apps.navigator && typeof this.apps.navigator.launch === 'function') { this.apps.navigator.launch(interpolatedAction.url); } break; case 'navigator.redirect': if (this.apps.navigator && typeof this.apps.navigator.navigateTo === 'function') { this.apps.navigator.navigateTo(interpolatedAction.url); } break; // Files actions case 'files.create': if (this.apps.files && typeof this.apps.files.addFile === 'function') { this.apps.files.addFile(interpolatedAction.file); } break; case 'files.open': { const fileId = interpolatedAction.fileId; const fileObj = this.scenario.files?.find(f => f.id === fileId); if (!fileObj) { throw new Error(`File ID '${fileId}' not found in scenario.files`); } if (this.apps.docViewer && typeof this.apps.docViewer.openDocument === 'function') { this.apps.docViewer.openDocument(fileObj); } break; } // Security Center actions case 'security.addAlert': { let alertObj; if (typeof interpolatedAction.alert === 'string') { alertObj = this.scenario.alerts?.find(a => a.id === interpolatedAction.alert); if (!alertObj) { throw new Error(`Alert ID '${interpolatedAction.alert}' not found in scenario.alerts`); } } else { alertObj = interpolatedAction.alert; } if (this.scenarioState && typeof this.scenarioState.addAlert === 'function') { this.scenarioState.addAlert(alertObj); } if (this.apps.securityCenter && typeof this.apps.securityCenter.addAlert === 'function') { this.apps.securityCenter.addAlert(alertObj); } break; } case 'security.updateStatus': if (this.apps.securityCenter && typeof this.apps.securityCenter.updateStatus === 'function') { this.apps.securityCenter.updateStatus(interpolatedAction.status); } break; // Evaluation actions case 'evaluation.addFinding': { const findingId = interpolatedAction.finding; const findingDef = this.scenario.findings?.find(f => f.id === findingId); if (!findingDef) { throw new Error(`Finding ID '${findingId}' not found in scenario.findings`); } if (this.scenarioState && typeof this.scenarioState.addFinding === 'function') { this.scenarioState.addFinding(findingId, findingDef); } if (findingDef.score && findingDef.category && this.scenarioState && typeof this.scenarioState.awardPoints === 'function') { this.scenarioState.awardPoints(findingDef.category, findingDef.score); } break; } case 'evaluation.setState': if (this.scenarioState && typeof this.scenarioState.setState === 'function') { this.scenarioState.setState(interpolatedAction.object, interpolatedAction.key, interpolatedAction.value); } break; case 'evaluation.completeObjective': if (this.scenarioState && typeof this.scenarioState.completeObjective === 'function') { this.scenarioState.completeObjective(interpolatedAction.objective); } break; case 'evaluation.awardPoints': if (this.scenarioState && typeof this.scenarioState.awardPoints === 'function') { this.scenarioState.awardPoints(interpolatedAction.category, interpolatedAction.points); } break; case 'evaluation.revealFeedback': this.eventBus.emit('EVALUATION_REVEAL_FEEDBACK', { feedback: interpolatedAction.feedback }); break; } } /** * Handle desktop.notify action. * @private * @param {Object} action - The action object. */ _handleDesktopNotify(action) { if (!this.notifications) return; if (action.notification) { const notifDef = this.scenario.notifications?.find(n => n.id === action.notification); if (!notifDef) { throw new Error(`Notification ID '${action.notification}' not found in scenario.notifications`); } this.notifications.show(notifDef); } else { this.notifications.show({ title: action.title, body: action.body, type: action.type, timeout: action.timeout, icon: action.icon }); } } /** * Build the context object for template interpolation. * @private * @returns {Object} The context object. */ _buildInterpolationContext() { const learner = this.scenario.learner || {}; const orgs = this.scenario.organizations || []; const company = orgs.length > 0 ? orgs[0] : {}; let timestamp = new Date().toLocaleTimeString(); if (this.scenarioState && typeof this.scenarioState.getCurrentTime === 'function') { const simulatedTime = this.scenarioState.getCurrentTime(); if (simulatedTime) { timestamp = new Date(simulatedTime).toLocaleTimeString(); } } return { 'learner.name': learner.name || 'User', 'learner.email': learner.email || 'user@example.com', 'learner.role': learner.role || 'Employee', 'company.name': company.name || 'Company', 'timestamp': timestamp }; } /** * Interpolate templates in a string. * @param {string} template - The template string (e.g., "Hello {{learner.name}}"). * @param {Object} context - The context object mapping keys to values. * @returns {string} The interpolated string. */ interpolate(template, context) { if (typeof template !== 'string') return template; return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => { const trimmedKey = key.trim(); if (context.hasOwnProperty(trimmedKey)) { return context[trimmedKey]; } return match; }); } /** * Recursively interpolate properties in an object. * @private * @param {*} obj - The object or value to interpolate. * @param {Object} context - The context object. * @returns {*} A new object or value with interpolated strings. */ _interpolateProperties(obj, context) { if (obj === null || obj === undefined) return obj; if (typeof obj === 'string') return this.interpolate(obj, context); if (Array.isArray(obj)) return obj.map(item => this._interpolateProperties(item, context)); if (typeof obj === 'object') { const newObj = {}; for (const [key, value] of Object.entries(obj)) { newObj[key] = this._interpolateProperties(value, context); } return newObj; } return obj; } }