Files
scrying-pool/module.js
T
uberwald 3faab3e3e4
CI / ci (push) Successful in 44s
CLeanup unused stuff
2026-05-25 21:35:02 +02:00

441 lines
18 KiB
JavaScript

// @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 { ScryingPoolSettings } from './src/ui/gm/ScryingPoolSettings.js';
import { SOCKET_EVENTS } from './src/contracts/socket-message.js';
// Factory function to create ScryingPoolSettings with roleRenderer dependency
// Returns a class constructor (not a function) that Foundry can use for registerMenu
function initScryingPoolSettings(roleRendererRef) {
return class extends ScryingPoolSettings {
constructor(options = {}) {
super(roleRendererRef, options);
}
};
}
// 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');
// Show info message to GM about AV being disabled
if (game.user?.isGM) {
ui.notifications?.info(game.i18n.localize('SCRYING_POOL.Notifications.AVDisabled'));
}
} 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,
});
// Register ScryingPoolSettings in module settings
// Provides button to reopen the strip when user closes it
game.settings.registerMenu('scrying-pool', 'stripSettings', {
name: 'SCRYING_POOL.Settings.Title',
label: 'SCRYING_POOL.Settings.Title',
hint: 'SCRYING_POOL.Settings.Hint',
icon: 'fa-solid fa-cog',
type: initScryingPoolSettings(roleRenderer),
restricted: true, // GM only
});
} catch (err) {
console.error('[ScryingPool] Module initialization failed:', err);
throw err; // Re-throw to prevent module from loading in broken state
}
});