Files
CyberSim-OS/src/js/engine/action_dispatcher.js
T

331 lines
12 KiB
JavaScript

/**
* 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 || '[email protected]',
'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;
}
}