// @ts-nocheck — Module entry point with FoundryVTT globals, no exports needed /* global Handlebars */ /** * module.js — Entry point and wiring diagram for Scrying Pool. * * This file is the wiring diagram ONLY. It imports all modules, constructs them * with injected dependencies, and holds NO business logic. * * Initialisation order: * Hooks.once('init') → register world settings → construct FoundryAdapter * → StateStore → SocketHandler (queue+drain) * Hooks.once('ready') → hydrate StateStore → probe WebRTC * → VisibilityManager → SocketHandler.setReady() * → ScryingPoolController * → AVTileAdapter → RoleRenderer → openStrip (GM only) * → VisibilityBadge (player only) — Story 1.6 * → NotificationBus (all clients) — Story 2.1 * → Story 2.2: DirectorsBoard (lazy, GM only) */ import { FoundryAdapter } from './src/foundry/FoundryAdapter.js'; import { StateStore } from './src/core/StateStore.js'; import { SocketHandler } from './src/core/SocketHandler.js'; import { VisibilityManager } from './src/core/VisibilityManager.js'; import { ScryingPoolController } from './src/core/ScryingPoolController.js'; import { ScenePresetManager } from './src/core/ScenePresetManager.js'; import { PlayerPrivacyManager } from './src/core/PlayerPrivacyManager.js'; import { PortraitFallbackHandler } from './src/core/PortraitFallbackHandler.js'; import { AVTileAdapter } from './src/ui/shared/AVTileAdapter.js'; import { RoleRenderer } from './src/ui/RoleRenderer.js'; import { VisibilityBadge } from './src/ui/player/VisibilityBadge.js'; import { NotificationBus } from './src/notifications/NotificationBus.js'; import { DirectorsBoard } from './src/ui/gm/DirectorsBoard.js'; import { ConfirmationBar } from './src/ui/gm/ConfirmationBar.js'; import { PlayerPrivacyPanelMenu, initPlayerPrivacyPanelMenu } from './src/ui/player/PlayerPrivacyPanelMenu.js'; import { initGMPlayerPrivacySelector } from './src/ui/gm/GMPlayerPrivacySelector.js'; import { ScryingPoolCameraViews, initScryingPoolCameraViews } from './src/ui/shared/ScryingPoolCameraViews.js'; import { SOCKET_EVENTS } from './src/contracts/socket-message.js'; // Module-level references — constructed in init hook, used across hooks let adapter; let stateStore; let socketHandler; let visibilityManager; let scryingPoolController; let scenePresetManager; let playerPrivacyManager; let portraitFallbackHandler; let avTileAdapter; let roleRenderer; let visibilityBadge; let notificationBus; let directorsBoard; let confirmationBar; /** @type {boolean} Flag to prevent duplicate scene control button addition */ let directorsBoardButtonAdded = false; Hooks.once("init", () => { console.log("[ScryingPool] init — module loading"); // Foundry sets body class from URL route. If the URL has a query string // (e.g. /game?presetName=ALL), the class becomes "game?presetName=ALL" // instead of "game", breaking body.game { display:flex } and collapsing the UI. const badClass = Array.from(document.body.classList).find(c => c.startsWith("game") && c !== "game"); if (badClass) { document.body.classList.remove(badClass); document.body.classList.add("game"); } // Take over Foundry's camera dock — must be set before Foundry instantiates ui.webrtc CONFIG.ui.webrtc = ScryingPoolCameraViews; // WebRTC mode setting — determines how the module handles AV integration // Updated for FULL REPLACEMENT architecture (hiding Foundry's dock, showing our own) game.settings.register("scrying-pool", "webrtcMode", { scope: "world", config: false, type: String, default: "stream-access", choices: { "stream-access": "Stream Access (full replacement - hide Foundry dock, show VVM dock with actual video)", "track-disable": "Track Disable (bandwidth-saving) - DEPRECATED", "css-fallback": "CSS Fallback (cosmetic hiding) - DEPRECATED", "unsupported": "Unsupported (AV not available)", }, }); // Construct adapter first — needed for settings registration adapter = new FoundryAdapter(game); // Story 1.3: register remaining world settings via adapter adapter.settings.register("visibilityMatrix", { scope: "world", config: false, type: Object, default: { _version: 1, matrix: {} }, onChange: () => { stateStore?.init(); roleRenderer?.rerenderStrip(); }, }); adapter.settings.register("showGMSelfFeed", { scope: "world", config: true, type: Boolean, default: true, name: "Show GM Self Feed", hint: "When enabled, the GM's own camera feed is shown in the Scrying Pool strip.", }); // Story 2.1: per-user notification verbosity preference (client-scoped) adapter.settings.register("notificationVerbosity", { scope: "client", config: true, type: String, name: "Notification Verbosity", hint: "Controls which camera-state notifications you see. 'All' shows every change; 'GM Only' shows changes only to the GM and affected participant; 'Silent' suppresses all notifications except your own camera changes.", choices: { all: "All", "gm-only": "GM Only", silent: "Silent", }, default: "all", }); // Story 3.2: Global auto-apply enable/disable setting adapter.settings.register("autoApplyEnabled", { scope: "world", config: true, type: Boolean, default: true, name: "Enable Camera Layout Auto-Apply", hint: "When enabled, scenes with a configured camera layout will automatically apply it on activation", }); // Dock layout — world-scoped so the GM controls the layout direction for all players adapter.settings.register("dockLayout", { scope: "world", config: false, type: String, default: "vertical-sm", onChange: () => roleRenderer?.rerenderStrip(), }); // Per-user size toggle — client-scoped so each user can expand/collapse independently. // '' = no preference (follow world dockLayout), 'sm' = force small, 'md' = force large. adapter.settings.register("dockLayoutExpanded", { scope: "client", config: false, type: String, default: "", onChange: () => roleRenderer?.rerenderStrip(), }); // Story 5.2: Video widget width customization — world-scoped settings for small and large tile widths adapter.settings.register("widgetWidthSm", { scope: "world", config: false, type: String, default: "83", onChange: () => roleRenderer?.rerenderStrip(), }); adapter.settings.register("widgetWidthMd", { scope: "world", config: false, type: String, default: "150", onChange: () => roleRenderer?.rerenderStrip(), }); // Construct data layer — constructors are side-effect-free // Note: ScenePresetManager is constructed in 'ready' hook after visibilityManager is available stateStore = new StateStore(adapter.settings); socketHandler = new SocketHandler(adapter.socket, adapter.hooks); // Story 2.2: GM-only keyboard shortcut to open/close Director's Board game.keybindings.register('scrying-pool', 'openDirectorsBoard', { name: "Open/Close Director's Board", hint: "Toggles the Director's Board window", editable: [{ key: 'KeyV', modifiers: ['Control', 'Shift'] }], restricted: true, // GM only onDown: () => directorsBoard?.toggle(), }); // Story 2.3: Bulk-action keybindings (GM only, migrated to scrying-pool namespace) game.keybindings.register('scrying-pool', 'showAll', { name: game.i18n.localize('scrying-pool.keybindings.showAll.name'), hint: game.i18n.localize('scrying-pool.keybindings.showAll.hint'), editable: [{ key: 'KeyS', modifiers: ['Control', 'Shift'] }], restricted: true, onDown: () => directorsBoard?.showAll(), }); game.keybindings.register('scrying-pool', 'hideAll', { name: game.i18n.localize('scrying-pool.keybindings.hideAll.name'), hint: game.i18n.localize('scrying-pool.keybindings.hideAll.hint'), editable: [{ key: 'KeyH', modifiers: ['Control', 'Shift'] }], restricted: true, onDown: () => directorsBoard?.hideAll(), }); game.keybindings.register('scrying-pool', 'spotlightParticipant', { name: game.i18n.localize('scrying-pool.keybindings.spotlightParticipant.name'), hint: game.i18n.localize('scrying-pool.keybindings.spotlightParticipant.hint'), editable: [{ key: 'KeyP', modifiers: ['Control', 'Shift'] }], restricted: true, onDown: () => directorsBoard?.spotlightFocused(), }); // Story 2.2: Inject GM-only sidebar button via scene controls hook. // Uses the strip footer CTA pattern if getSceneControlButtons API is unavailable. Hooks.on('getSceneControlButtons', (controls) => { // Prevent duplicate button addition if (directorsBoardButtonAdded) return; if (!game.user?.isGM) return; const tokenGroup = controls.find?.(c => c.name === 'token'); if (!tokenGroup?.tools) return; // Check if button already exists if (tokenGroup.tools.some(t => t.name === 'directors-board')) return; tokenGroup.tools.push({ name: 'directors-board', title: "Director's Board", icon: 'fas fa-border-all', onClick: () => directorsBoard?.toggle(), button: true, }); directorsBoardButtonAdded = true; }); }); Hooks.once("ready", () => { console.log("[ScryingPool] ready — module active"); // Migration: reset stale boolean dockLayoutExpanded to '' (empty string) // Old registration used type:Boolean, saved value `false` persists in client storage try { const legacyVal = adapter.settings.get('dockLayoutExpanded'); if (typeof legacyVal === 'boolean') { adapter.settings.set('dockLayoutExpanded', '').catch(() => {}); } } catch (_) {} // Hydrate StateStore from persisted world setting (AC-6, AC-7) stateStore.init(); // Probe WebRTC capability and set adapter.webrtc // For FULL REPLACEMENT: we need stream access to create our own video tiles // Migration: if webrtcMode is an old deprecated value, probe fresh capability // This ensures existing installations get the new stream-access mode if available const currentWebRtcMode = adapter.settings.get(FoundryAdapter.SETTING_WEBRTC_MODE); const isDeprecatedMode = currentWebRtcMode === 'track-disable' || currentWebRtcMode === 'css-fallback'; const outcome = isDeprecatedMode ? FoundryAdapter.probeCapability(game.webrtc) : currentWebRtcMode || FoundryAdapter.probeCapability(game.webrtc); adapter.settings?.set(FoundryAdapter.SETTING_WEBRTC_MODE, outcome).catch(err => { console.error('[ScryingPool] Failed to set webrtcMode setting:', err); }); // Build WebRTC surface for stream access (full replacement mode) try { if (outcome === 'stream-access') { adapter.webrtc = FoundryAdapter.buildWebRTCSurface(game.webrtc); console.log('[ScryingPool] WebRTC stream access available - full replacement mode enabled'); } else if (outcome === 'unsupported') { adapter.webrtc = null; console.log('[ScryingPool] WebRTC not available - AV features disabled'); } else { // Legacy: track-disable or css-fallback (deprecated) adapter.webrtc = FoundryAdapter.buildWebRTCSurface(game.webrtc); console.warn('[ScryingPool] WebRTC mode is deprecated:', outcome, '- consider using stream-access'); } } catch (err) { console.error('[ScryingPool] Failed to build WebRTC surface:', err); adapter.webrtc = null; } // Wire core managers — construct both before setReady so handler can reference both visibilityManager = new VisibilityManager(stateStore, adapter); scryingPoolController = new ScryingPoolController(stateStore, socketHandler, adapter); // Story 3.2: Re-construct ScenePresetManager with visibilityManager for auto-apply scenePresetManager = new ScenePresetManager(adapter, stateStore, socketHandler, visibilityManager); // Story 4.1: Create PlayerPrivacyManager for automation opt-ins playerPrivacyManager = new PlayerPrivacyManager(adapter); // Story 4.2: Create PortraitFallbackHandler for custom portrait fallback portraitFallbackHandler = new PortraitFallbackHandler(adapter, playerPrivacyManager); portraitFallbackHandler.init(); // Story 3.2: Register updateScene hook for auto-apply adapter.hooks.on('updateScene', (scene) => { if (adapter.users.isGM()) { scenePresetManager.onSceneActivate(scene); } }); // Story 3.1: Initialize ScenePresetManager to load presets from current scene scenePresetManager.init(); // Set up composite handler for SocketHandler timeout callbacks // This allows cleanup of ScryingPoolController._pendingOps when onRevert fires socketHandler.setReady({ onRevert: (pendingOp) => { visibilityManager.onRevert(pendingOp); scryingPoolController.cleanupPendingOp(pendingOp.userId); } }); // Initialize both managers — must come after setReady so queue is drained // before echo listener is registered (prevents early echo loss) try { visibilityManager.init(); scryingPoolController.init(); // Story 1.5: AV tile integration + GM control UI avTileAdapter = new AVTileAdapter(adapter); // Story 4.2: Pass portraitFallbackHandler for custom portrait display roleRenderer = new RoleRenderer(stateStore, scryingPoolController, avTileAdapter, adapter, portraitFallbackHandler); roleRenderer.init(); roleRenderer.openStrip(); if (adapter.users.isGM()) { // Story 3.2: Create ConfirmationBar for preset apply feedback (GM only) // Pass roleRenderer to access ScryingPoolStrip.stripOverlayLayer (created lazily) confirmationBar = new ConfirmationBar(adapter, visibilityManager, socketHandler, roleRenderer); confirmationBar.init(); } if (!adapter.users.isGM()) { visibilityBadge = new VisibilityBadge(stateStore, scryingPoolController, avTileAdapter, adapter); visibilityBadge.init(); } // Story 2.1: NotificationBus — runs for all clients (GM and players) notificationBus = new NotificationBus(adapter); notificationBus.init(); // Story 3.1: Register socket listener for preset apply echo (all clients receive) // Note: In Foundry, socket messages are automatically broadcast to all clients. // The GM emits PRESET_APPLIED, and all clients (including GM) receive it. // We skip processing on the GM since they already applied it locally. adapter.socket.on(SOCKET_EVENTS.PRESET_APPLIED, async (payload) => { try { // Validate payload if (!payload || typeof payload !== 'object' || typeof payload.presetName !== 'string') { console.warn('[ScryingPool] Invalid PRESET_APPLIED payload:', payload); return; } // Skip on GM — they already applied the preset locally if (adapter.users.isGM()) { return; } // Load the preset on this client (emitSocket: false to prevent loop) await scenePresetManager.load(payload.presetName, { emitSocket: false }); } catch (err) { console.error('[ScryingPool] Failed to handle PRESET_APPLIED:', err); } }); // Story 2.2: DirectorsBoard (lazy, GM only) // Story 3.1: Pass scenePresetManager for preset save/load functionality // Story 4.1: Pass playerPrivacyManager for Reaction Cam badge display if (adapter.users.isGM()) { directorsBoard = new DirectorsBoard(stateStore, scryingPoolController, adapter, scenePresetManager, playerPrivacyManager); directorsBoard.init(); window.directorsBoard = directorsBoard; } // Inject Scrying Pool deps into our camera views replacement (all clients) // Directors Board reference is GM-only — players get null so _onConfigure is a no-op initScryingPoolCameraViews( adapter.users.isGM() ? directorsBoard : null, stateStore ); // Pre-load participant-card as a Handlebars partial for directors-board // ApplicationV2 requires partials to be registered explicitly (async () => { try { const resp = await fetch('modules/scrying-pool/templates/participant-card.hbs'); const source = await resp.text(); Handlebars.registerPartial('modules/scrying-pool/templates/participant-card.hbs', source); } catch (err) { console.warn('[ScryingPool] Failed to register participant-card partial:', err); } })(); // Story 4.1: Initialize PlayerPrivacyPanelMenu with DI dependencies // Story 4.2: Pass portraitFallbackHandler for portrait selection initPlayerPrivacyPanelMenu(adapter, playerPrivacyManager, portraitFallbackHandler); // Story 4.1: Register GM-only Player Privacy Selector (Task 5.2) // Allows GM to select any player and view their privacy settings in read-only mode // Story 4.2: Pass portraitFallbackHandler for portrait display initGMPlayerPrivacySelector(adapter, playerPrivacyManager, portraitFallbackHandler); // Story 4.1: Register PlayerPrivacyPanel in module settings (Task 5.1) // Note: Must be registered AFTER init calls to avoid race conditions game.settings.registerMenu('scrying-pool', 'playerPrivacyPanel', { name: 'SCRYING_POOL.Settings.PlayerPrivacyPanel', label: 'SCRYING_POOL.Settings.PlayerPrivacyPanelLabel', hint: 'SCRYING_POOL.Settings.PlayerPrivacyPanelHint', icon: 'fa-solid fa-user-shield', type: PlayerPrivacyPanelMenu, restricted: false, }); } catch (err) { console.error('[ScryingPool] Module initialization failed:', err); throw err; // Re-throw to prevent module from loading in broken state } });