Files
scrying-pool/module.js
T
2026-05-24 00:37:21 +02:00

307 lines
13 KiB
JavaScript

// @ts-nocheck — Module entry point with FoundryVTT globals, no exports needed
/**
* module.js — Entry point and wiring diagram for Video View Manager (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 { StripOverlayLayer } from './src/ui/shared/StripOverlayLayer.js';
import { PlayerPrivacyPanelMenu, initPlayerPrivacyPanelMenu } from './src/ui/player/PlayerPrivacyPanelMenu.js';
import { initGMPlayerPrivacySelector } from './src/ui/gm/GMPlayerPrivacySelector.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 stripOverlayLayer;
let confirmationBar;
/** @type {boolean} Flag to prevent duplicate scene control button addition */
let directorsBoardButtonAdded = false;
Hooks.once("init", () => {
console.log("[ScryingPool] init — module loading");
// OQ-1 resolved (Story 1.2 spike): probe result is 'css-fallback' — see FoundryAdapter.js
game.settings.register("scrying-pool", "webrtcMode", {
scope: "world",
config: false,
type: String,
default: "css-fallback",
choices: {
"track-disable": "Track Disable (bandwidth-saving)",
"css-fallback": "CSS Fallback (cosmetic hiding)",
"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: {} },
});
adapter.settings.register("showGMSelfFeed", {
scope: "world",
config: true,
type: Boolean,
default: true,
});
// Story 2.1: per-user notification verbosity preference (client-scoped)
adapter.settings.register("notificationVerbosity", {
scope: "client",
config: true,
type: String,
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 Scene Preset Auto-Apply",
hint: "When enabled, scenes with configured presets will automatically apply them on activation",
});
// 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('video-view-manager.keybindings.showAll.name'),
hint: game.i18n.localize('video-view-manager.keybindings.showAll.hint'),
editable: [{ key: 'KeyS', modifiers: ['Control', 'Shift'] }],
restricted: true,
onDown: () => directorsBoard?.showAll(),
});
game.keybindings.register('scrying-pool', 'hideAll', {
name: game.i18n.localize('video-view-manager.keybindings.hideAll.name'),
hint: game.i18n.localize('video-view-manager.keybindings.hideAll.hint'),
editable: [{ key: 'KeyH', modifiers: ['Control', 'Shift'] }],
restricted: true,
onDown: () => directorsBoard?.hideAll(),
});
game.keybindings.register('scrying-pool', 'spotlightParticipant', {
name: game.i18n.localize('video-view-manager.keybindings.spotlightParticipant.name'),
hint: game.i18n.localize('video-view-manager.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");
// Hydrate StateStore from persisted world setting (AC-6, AC-7)
stateStore.init();
// Probe WebRTC capability and set adapter.webrtc (AC-8)
const outcome = FoundryAdapter.probeCapability(game.webrtc);
adapter.webrtc = outcome === 'track-disable'
? FoundryAdapter.buildWebRTCSurface(game.webrtc)
: null;
adapter.settings.set(FoundryAdapter.SETTING_WEBRTC_MODE, outcome).catch(err => {
console.error('[ScryingPool] Failed to set webrtcMode setting:', err);
});
// 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: Create StripOverlayLayer (shared infrastructure for UI components)
stripOverlayLayer = new StripOverlayLayer(adapter);
stripOverlayLayer.init();
// Story 3.2: Create ConfirmationBar for preset apply feedback
confirmationBar = new ConfirmationBar(adapter, visibilityManager, socketHandler, stripOverlayLayer);
confirmationBar.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();
if (adapter.users.isGM() && game.webrtc !== null) {
roleRenderer.openStrip();
}
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();
}
// 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
}
});