generated from Labyricorn/labyricorn-project-template
331 lines
12 KiB
JavaScript
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;
|
|
}
|
|
}
|