/** * ScenePresetManager — Manages scene preset CRUD operations. * * Owns: preset creation, loading, deletion, renaming, listing. * Story 3.2: Added auto-apply logic with per-scene configuration. * Persists presets to Scene document flags. * Emits socket messages for preset apply operations. * * Import rule: may only import from src/contracts/ and src/utils/. * Constructors are side-effect free — call init() from module.js Hooks.once('ready'). * * @module core/ScenePresetManager */ import { createScenePreset, isValidScenePreset, MAX_PRESETS_PER_WORLD, SCENE_PRESET_VERSION } from '../contracts/scene-preset.js'; import { SOCKET_EVENTS } from '../contracts/socket-message.js'; // Story 3.2: Auto-apply constants /** @type {number} Maximum pre-delay in milliseconds */ const MAX_PREDELAY_MS = 5000; /** @type {number} Minimum pre-delay in milliseconds */ const MIN_PREDELAY_MS = 0; /** * Manages scene preset CRUD operations. * Persists presets to Scene document flags. * Emits socket messages for preset apply operations. * Story 3.2: Extended with auto-apply on scene activation. */ export class ScenePresetManager { /** * @param {import('../foundry/FoundryAdapter.js').FoundryAdapter} adapter * Injected FoundryAdapter surface. * @param {import('./StateStore.js').StateStore} stateStore * Injected StateStore for visibility matrix access. * @param {import('./SocketHandler.js').SocketHandler} socketHandler * Injected SocketHandler for socket message emission. * @param {import('./VisibilityManager.js').VisibilityManager|null} visibilityManager * Injected VisibilityManager for applying visibility matrix (Story 3.2). */ constructor(adapter, stateStore, socketHandler, visibilityManager = null) { // Validate dependencies if (!adapter || typeof adapter !== 'object') { throw new TypeError('ScenePresetManager: adapter argument is required and must be an object'); } if (!stateStore || typeof stateStore !== 'object') { throw new TypeError('ScenePresetManager: stateStore argument is required and must be an object'); } if (!socketHandler || typeof socketHandler !== 'object') { throw new TypeError('ScenePresetManager: socketHandler argument is required and must be an object'); } this._adapter = adapter; this._stateStore = stateStore; this._socketHandler = socketHandler; this._visibilityManager = visibilityManager; /** @type {Map} name → ScenePreset */ this._presetsCache = new Map(); /** @type {Map} sceneId → timeoutId for active pre-delay timers */ this._activeTimers = new Map(); } /** * Initializes the manager by loading presets from the current scene. * No hooks registered here for Story 3.1 — hooks for auto-apply come in Story 3.2. * Side-effect: loads presets into _presetsCache. */ init() { this._loadCurrentScenePresets(); } /** * Cleans up internal state. * Safe to call multiple times. */ teardown() { this._presetsCache.clear(); } /** * Saves the current Visibility Matrix as a named preset. * * @param {string} name - Preset name (non-empty string). * @returns {Promise} The created preset. * @throws {TypeError} If name is invalid, duplicate, or max presets reached. */ async save(name) { // Validate name if (typeof name !== 'string' || name.length === 0) { throw new TypeError('ScenePresetManager.save: name must be a non-empty string'); } // Get current scene const currentScene = this._adapter.scenes.current?.(); if (!currentScene) { throw new TypeError('ScenePresetManager.save: no active scene'); } // Check max presets limit if (this._presetsCache.size >= MAX_PRESETS_PER_WORLD) { throw new TypeError( `ScenePresetManager.save: maximum of ${MAX_PRESETS_PER_WORLD} presets reached. Delete an existing preset to save a new one.` ); } // Check for duplicate name if (this._presetsCache.has(name)) { throw new TypeError(`ScenePresetManager.save: a preset with name "${name}" already exists`); } // Get current visibility matrix const matrixData = this._stateStore.getMatrix(); const matrix = matrixData.matrix; // Create preset const preset = createScenePreset(name, matrix); // Add to cache this._presetsCache.set(name, preset); // Persist to scene flag await this._saveScenePresets(); // Emit notification this._adapter.notifications.info( this._adapter.i18n.localize('video-view-manager.presets.notifications.saved') .replace('{name}', name) ); return preset; } /** * Loads a preset by name, applying its matrix to the current visibility state. * * @param {string} name - Preset name to load. * @param {object} options - Options object. * @param {boolean} [options.emitSocket=true] - Whether to emit socket message. * Set to false when called in response to a socket message to prevent loops. * @returns {Promise} * @throws {TypeError} If name is invalid or preset not found. */ async load(name, options = {}) { const { emitSocket = true } = options; // Validate name if (typeof name !== 'string' || name.length === 0) { throw new TypeError('ScenePresetManager.load: name must be a non-empty string'); } // Get preset from cache (loaded via init or save) const preset = this._presetsCache.get(name); if (!preset) { throw new TypeError(`ScenePresetManager.load: preset "${name}" not found`); } // Validate preset structure isValidScenePreset(preset); // Apply via VisibilityManager if available (Story 3.2), otherwise fall back to StateStore if (this._visibilityManager) { await this._visibilityManager.applyMatrix(preset.matrix); } else { await this._stateStore.setMatrix({ _version: preset._version, matrix: { ...preset.matrix }, }); } // Emit socket message only if requested (prevents loops when called from socket handler) if (emitSocket) { const now = Date.now(); this._socketHandler.emit(SOCKET_EVENTS.PRESET_APPLIED, { presetName: name, timestamp: now, }); } // Emit notification this._adapter.notifications.info( this._adapter.i18n.localize('video-view-manager.presets.notifications.applied') .replace('{name}', name) ); } /** * Deletes a preset by name. * * @param {string} name - Preset name to delete. * @returns {Promise} * @throws {TypeError} If name is invalid or preset not found. */ async delete(name) { // Validate name if (typeof name !== 'string' || name.length === 0) { throw new TypeError('ScenePresetManager.delete: name must be a non-empty string'); } // Check if preset exists if (!this._presetsCache.has(name)) { throw new TypeError(`ScenePresetManager.delete: preset "${name}" not found`); } // Delete from cache this._presetsCache.delete(name); // Persist changes await this._saveScenePresets(); } /** * Renames a preset. * * @param {string} oldName - Current preset name. * @param {string} newName - New preset name. * @returns {Promise} The renamed preset. * @throws {TypeError} If names are invalid, oldName not found, or newName conflicts. */ async rename(oldName, newName) { // Validate oldName if (typeof oldName !== 'string' || oldName.length === 0) { throw new TypeError('ScenePresetManager.rename: oldName must be a non-empty string'); } // Validate newName if (typeof newName !== 'string' || newName.length === 0) { throw new TypeError('ScenePresetManager.rename: newName must be a non-empty string'); } // Check if oldName exists if (!this._presetsCache.has(oldName)) { throw new TypeError(`ScenePresetManager.rename: preset "${oldName}" not found`); } // Check if newName conflicts if (oldName !== newName && this._presetsCache.has(newName)) { throw new TypeError( `ScenePresetManager.rename: a preset with name "${newName}" already exists` ); } // Get preset const preset = this._presetsCache.get(oldName); // Delete old entry this._presetsCache.delete(oldName); // Update preset name and timestamps const now = Date.now(); const renamedPreset = { ...preset, name: newName, updatedAt: now, }; // Validate the renamed preset structure isValidScenePreset(/** @type {import('../contracts/scene-preset.js').ScenePreset} */ (renamedPreset)); // Add with new name this._presetsCache.set(newName, /** @type {import('../contracts/scene-preset.js').ScenePreset} */ (renamedPreset)); // Persist changes await this._saveScenePresets(); return /** @type {import('../contracts/scene-preset.js').ScenePreset} */ (renamedPreset); } /** * Returns all presets for the current scene. * * @returns {Array} Array of preset objects. */ list() { return Array.from(this._presetsCache.values()); } /** * Returns a specific preset by name, or null if not found. * * @param {string} name - Preset name. * @returns {import('../contracts/scene-preset.js').ScenePreset|null} */ get(name) { return this._presetsCache.get(name) ?? null; } /** * Loads presets from the current scene's flag. * @private */ _loadCurrentScenePresets() { this._presetsCache.clear(); const currentScene = this._adapter.scenes.current?.(); if (!currentScene) { return; } try { const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (currentScene).getFlag?.('video-view-manager', 'presets'); if (!flagData || typeof flagData !== 'object') { return; // No presets or invalid format } // Type assert flagData as having _version and presets const flag = /** @type {{ _version: number; presets?: Record }} */ (flagData); if (flag._version !== SCENE_PRESET_VERSION) { console.warn( `[ScryingPool] ScenePresetManager: unsupported presets schema version ${flag._version}, expected ${SCENE_PRESET_VERSION}` ); return; } const { presets } = flag; if (!presets || typeof presets !== 'object') { return; } for (const [name, presetData] of Object.entries(presets)) { try { const validated = isValidScenePreset(presetData); this._presetsCache.set(name, validated); } catch (err) { console.warn( `[ScryingPool] ScenePresetManager: invalid preset "${name}" in scene flag, skipping: ${err instanceof Error ? err.message : String(err)}` ); } } } catch (err) { console.warn( '[ScryingPool] ScenePresetManager: failed to load scene presets', err instanceof Error ? err.message : String(err) ); } } /** * Saves presets to the current scene's flag. * @private * @returns {Promise} */ async _saveScenePresets() { const currentScene = this._adapter.scenes.current?.(); if (!currentScene) { throw new TypeError('ScenePresetManager._saveScenePresets: no active scene'); } /** @type {Record} */ const presetsObj = {}; for (const [name, preset] of this._presetsCache) { presetsObj[name] = preset; } // Get existing flag data to preserve autoApply config const existingFlag = this._getSceneFlagData(currentScene); const autoApply = existingFlag?.autoApply ?? null; const flagData = { _version: SCENE_PRESET_VERSION, presets: presetsObj, ...(autoApply && { autoApply }), }; try { await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise }} */ (currentScene).setFlag?.('video-view-manager', 'presets', flagData); } catch (err) { console.error( '[ScryingPool] ScenePresetManager: failed to save scene presets', err instanceof Error ? err.message : String(err) ); throw err; } } // ========================================================================== // Story 3.2: Auto-Apply Methods // ========================================================================== /** * Handles scene activation for auto-apply functionality. * Checks global enable, per-scene config, and applies preset after pre-delay. * * @param {object} scene - The activated FoundryVTT Scene document. * @returns {Promise} */ async onSceneActivate(scene) { // Check if auto-apply is globally enabled const globalEnabled = this._adapter.settings.get?.('video-view-manager.autoApplyEnabled') ?? true; if (!globalEnabled) { return; // Global disable } // Get current scene's flag data const flagData = this._getSceneFlagData(scene); if (!flagData) { return; // No preset data for this scene } // Get auto-apply config (with defaults) const autoApplyConfig = this._getAutoApplyConfig(flagData); if (!autoApplyConfig.enabled) { return; // Per-scene disable } // Check if preset exists const preset = this._presetsCache.get(autoApplyConfig.presetName); if (!preset) { console.warn( `[ScryingPool] ScenePresetManager.onSceneActivate: preset "${autoApplyConfig.presetName}" not found in cache` ); return; } // Clear ALL pending timers when any scene is activated (prevents old scene timer from firing) this._clearAllTimers(); // Apply preset after pre-delay await this._applyWithDelay(scene, autoApplyConfig.presetName, autoApplyConfig.preDelay); } /** * Applies a preset with optional auto-apply flag. * Story 3.2: Extended to support auto-applied presets. * * @param {string} presetName - Name of the preset to apply. * @param {object} options - Options object. * @param {boolean} options.autoApplied - Whether this is an auto-apply operation. * @returns {Promise} * @throws {TypeError} If preset not found. */ async applyPreset(presetName, options = {}) { const { autoApplied = false } = options; // Validate presetName if (typeof presetName !== 'string' || presetName.length === 0) { throw new TypeError('ScenePresetManager.applyPreset: presetName must be a non-empty string'); } // Get preset from cache const preset = this._presetsCache.get(presetName); if (!preset) { throw new TypeError(`ScenePresetManager.applyPreset: preset "${presetName}" not found`); } // Validate preset structure isValidScenePreset(preset); // Apply via VisibilityManager if available (Story 3.2), otherwise fall back to StateStore if (this._visibilityManager) { await this._visibilityManager.applyMatrix(preset.matrix); } else { await this._stateStore.setMatrix({ _version: preset._version, matrix: { ...preset.matrix }, }); } // Emit socket message for preset apply const now = Date.now(); this._socketHandler.emit(SOCKET_EVENTS.PRESET_APPLIED, { presetName, timestamp: now, autoApplied, }); // Also emit hook for local confirmation (ConfirmationBar) this._adapter.hooks.callAll('scrying-pool:presetApplied', { presetName, matrix: preset.matrix, autoApplied, timestamp: now, }); } /** * Configures auto-apply settings for a scene. * * @param {object} scene - The FoundryVTT Scene document. * @param {object} config - Auto-apply configuration. * @param {boolean} config.enabled - Whether auto-apply is enabled. * @param {string|null} config.presetName - Name of preset to auto-apply. * @param {number} config.preDelay - Pre-delay in milliseconds (0-5000). * @returns {Promise} * @throws {TypeError} If validation fails. */ async configureAutoApply(scene, config) { // Validate scene if (!scene || typeof scene !== 'object') { throw new TypeError('ScenePresetManager.configureAutoApply: scene argument is required and must be an object'); } // Validate config if (!config || typeof config !== 'object') { throw new TypeError('ScenePresetManager.configureAutoApply: config argument is required and must be an object'); } const { enabled, presetName, preDelay } = config; // Validate enabled if (typeof enabled !== 'boolean') { throw new TypeError('ScenePresetManager.configureAutoApply: enabled must be a boolean'); } // Validate presetName if (presetName !== null && typeof presetName !== 'string') { throw new TypeError('ScenePresetManager.configureAutoApply: presetName must be a string or null'); } // Validate preDelay if (typeof preDelay !== 'number' || preDelay < MIN_PREDELAY_MS || preDelay > MAX_PREDELAY_MS) { throw new TypeError( `ScenePresetManager.configureAutoApply: preDelay must be a number between ${MIN_PREDELAY_MS} and ${MAX_PREDELAY_MS}` ); } // Get current flag data const currentFlag = this._getSceneFlagData(scene); const currentPresets = currentFlag?.presets ?? {}; // Build new flag data const newFlagData = { _version: SCENE_PRESET_VERSION, presets: currentPresets, autoApply: { enabled, presetName: presetName ?? null, preDelay, }, }; // Persist to scene flag try { await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise }} */ (scene).setFlag?.('video-view-manager', 'presets', newFlagData); } catch (err) { console.error( '[ScryingPool] ScenePresetManager: failed to configure auto-apply', err instanceof Error ? err.message : String(err) ); throw err; } } /** * Gets auto-apply configuration from scene flag data. * Returns defaults if autoApply field is missing. * * @param {object} flagData - The scene flag data. * @returns {object} Auto-apply configuration with defaults. * @private */ _getAutoApplyConfig(flagData) { if (!flagData || typeof flagData !== 'object') { return { enabled: false, presetName: null, preDelay: 0 }; } const { autoApply } = flagData; if (!autoApply || typeof autoApply !== 'object') { return { enabled: false, presetName: null, preDelay: 0 }; } return { enabled: Boolean(autoApply.enabled), presetName: typeof autoApply.presetName === 'string' ? autoApply.presetName : null, preDelay: typeof autoApply.preDelay === 'number' ? Math.max(MIN_PREDELAY_MS, Math.min(autoApply.preDelay, MAX_PREDELAY_MS)) : 0, }; } /** * Applies a preset after a specified delay. * * @param {object} scene - The FoundryVTT Scene document. * @param {string} presetName - Name of the preset to apply. * @param {number} delayMs - Delay in milliseconds. * @returns {number} Timeout ID for potential cancellation. * @private */ _applyWithDelay(scene, presetName, delayMs) { const timerId = setTimeout(async () => { try { await this.applyPreset(presetName, { autoApplied: true }); // Notify via ui.notifications this._adapter.notifications.info( this._adapter.i18n.localize('video-view-manager.presets.notifications.scene-applied') .replace('{name}', presetName) ); } catch (err) { console.error( '[ScryingPool] ScenePresetManager: failed to apply preset on scene activation', err instanceof Error ? err.message : String(err) ); } finally { // Clean up timer reference this._activeTimers.delete(scene.id); } }, delayMs); // Store timer reference for cancellation if (scene?.id) { this._activeTimers.set(scene.id, timerId); } return timerId; } /** * Clears the active timer for a scene. * * @param {object} scene - The FoundryVTT Scene document. * @private */ _clearSceneTimer(scene) { if (!scene?.id) return; const timerId = this._activeTimers.get(scene.id); if (timerId) { clearTimeout(timerId); this._activeTimers.delete(scene.id); } } /** * Clears ALL active timers. * Called when a new scene is activated to prevent old scene timers from firing. * @private */ _clearAllTimers() { for (const [, timerId] of this._activeTimers) { clearTimeout(timerId); } this._activeTimers.clear(); } /** * Gets the complete flag data from a scene. * * @param {object} scene - The FoundryVTT Scene document. * @returns {object|null} The flag data or null. * @private */ _getSceneFlagData(scene) { try { const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (scene).getFlag?.('video-view-manager', 'presets'); if (!flagData || typeof flagData !== 'object') { return null; } return /** @type {object} */ (flagData); } catch (err) { console.warn( '[ScryingPool] ScenePresetManager: failed to get scene flag data', err instanceof Error ? err.message : String(err) ); return null; } } }