6bbde5c1cf
CI / ci (push) Successful in 46s
Features: - Added ScryingPoolSettings application with reopen/close strip buttons - Registered settings menu in module settings (GM only) - Added template for settings panel with styled buttons - Added translations for settings UI - Added info notification when AV is not available Files: - src/ui/gm/ScryingPoolSettings.js: New settings application - templates/settings.hbs: Settings panel template - lang/en.json: Added translations - module.js: Registered settings menu and AV notification Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
433 lines
18 KiB
JavaScript
433 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';
|
|
|
|
// Factory function to create ScryingPoolSettings with roleRenderer dependency
|
|
function initScryingPoolSettings(roleRendererRef) {
|
|
return () => new ScryingPoolSettings(roleRendererRef);
|
|
}
|
|
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');
|
|
// 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,
|
|
});
|
|
|
|
// Story 5.3: Register ScryingPoolSettings in module settings
|
|
// Provides button to reopen the strip
|
|
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
|
|
}
|
|
});
|
|
|