Files
scrying-pool/src/core/ScenePresetManager.js
T
2026-05-23 18:23:48 +02:00

655 lines
21 KiB
JavaScript

/**
* 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<string, import('../contracts/scene-preset.js').ScenePreset>} name → ScenePreset */
this._presetsCache = new Map();
/** @type {Map<string, number>} 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<import('../contracts/scene-preset.js').ScenePreset>} 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<void>}
* @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<void>}
* @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<import('../contracts/scene-preset.js').ScenePreset>} 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<import('../contracts/scene-preset.js').ScenePreset>} 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<string, unknown> }} */ (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<void>}
*/
async _saveScenePresets() {
const currentScene = this._adapter.scenes.current?.();
if (!currentScene) {
throw new TypeError('ScenePresetManager._saveScenePresets: no active scene');
}
/** @type {Record<string, import('../contracts/scene-preset.js').ScenePreset>} */
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<unknown> }} */ (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<void>}
*/
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<void>}
* @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<void>}
* @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<unknown> }} */ (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;
}
}
}