// OQ-1 Spike Result: css-fallback — FoundryVTT v14 — 2026-05-21 // // Investigation findings (Story 1.2 spike): // - game.webrtc is foundry.av.AVMaster — no getConnection(userId) method exists. // - Remote stream access: game.webrtc.client.getMediaStreamForUser(userId) // via the abstract AVClient public interface. // - track.enabled = false on remote inbound tracks does NOT stop WebRTC bandwidth. // RTP packets continue arriving from the remote peer regardless. // - True bandwidth elimination requires SDP renegotiation (not in AVMaster public API). // - LiveKit backend (CONFIG.WebRTC.clientClass override) removes peers/remoteStreams; // only getMediaStreamForUser() remains safe across backends. // // Conclusion: this.webrtc = null. scrying-pool.webrtcMode = 'css-fallback' (world default). // CSS/DOM cosmetic hiding is the only honest implementation path for Story 1.3+. /** * Sole gateway to game.* APIs for the Scrying Pool module. * * Exposes typed surfaces for: settings, socket, users, scenes, notifications, hooks, webrtc. * All surfaces are built in the constructor — construction is side-effect-free. * * The `webrtc` property starts as null and must be set externally at * Hooks.once('ready') via {@link FoundryAdapter.probeCapability} and * {@link FoundryAdapter.buildWebRTCSurface}. */ export class FoundryAdapter { /** Settings namespace for all scrying-pool world settings. */ static SETTINGS_NS = 'scrying-pool'; /** * World setting key for the resolved WebRTC capability mode. * Full identifier: `scrying-pool.webrtcMode`. * Value is one of: `'track-disable'` | `'css-fallback'` | `'unsupported'` */ static SETTING_WEBRTC_MODE = 'webrtcMode'; /** Flag scope/namespace for module-specific user flags. */ static FLAG_SCOPE = 'video-view-manager'; /** * Creates a FoundryAdapter. Side-effect-free — no hooks or listeners registered. * @param {object} [game] - The FoundryVTT `game` global. Optional for legacy/test * compatibility; surfaces will be no-ops if omitted. */ constructor(game) { this._game = game ?? {}; /** * WebRTC track-disabling surface, or null on the css-fallback/unsupported path. * Set externally at ready time via probeCapability + buildWebRTCSurface. * @type {{ disableTrack(userId: string): void, enableTrack(userId: string): void } | null} */ this.webrtc = null; const g = /** @type {any} */ (this._game); const ns = FoundryAdapter.SETTINGS_NS; /** * Settings surface — wraps game.settings, pre-namespaced to 'scrying-pool'. * Keys passed to these methods are the SHORT key (e.g. 'visibilityMatrix'), * NOT the fully-qualified key ('scrying-pool.visibilityMatrix'). */ this.settings = { /** * Register a setting. * @param {string} key - The setting key. * @param {object} config - The setting configuration. * @returns {void|undefined} */ register: (key, config) => g.settings?.register(ns, key, config), /** * Get a setting value. * @param {string} key - The setting key. * @returns {unknown} */ get: (key) => g.settings?.get(ns, key) ?? null, /** * Set a setting value. * @param {string} key - The setting key. * @param {unknown} value - The value to set. * @returns {Promise} */ set: (key, value) => /** @type {Promise} */ (g.settings?.set(ns, key, value) ?? Promise.resolve()), /** * Register a settings menu. * @param {string} namespace - The namespace. * @param {string} menuKey - The menu key. * @param {object} config - The menu configuration. * @returns {void|undefined} */ registerMenu: (namespace, menuKey, config) => g.settings?.registerMenu(namespace, menuKey, config), }; /** Socket surface — wraps game.socket. */ this.socket = { /** * Emit a socket event. * @param {string} event - The event name. * @param {object} payload - The event payload. * @returns {void|undefined} */ emit: (event, payload) => { if (g.socket) return g.socket.emit(event, payload); }, /** * Subscribe to a socket event. * @param {string} event - The event name. * @param {Function} handler - The event handler. * @returns {void|undefined} */ on: (event, handler) => { if (g.socket) return g.socket.on(event, handler); }, /** * Unsubscribe from a socket event. * @param {string} event - The event name. * @param {Function} handler - The event handler. * @returns {void|undefined} */ off: (event, handler) => { if (g.socket) return g.socket.off(event, handler); }, }; /** Users surface — wraps game.users / game.user. */ this.users = { /** * Get a user by ID. * @param {string} userId - The user ID. * @returns {object|null} The user object or null. */ get: (userId) => g.users?.get(userId) ?? null, /** * Get all users. * @returns {object[]} Array of all users. */ all: () => Array.from(g.users ?? []), /** * @param {string} [userId] - If omitted or null/undefined, checks current user. * @returns {boolean} */ isGM: (userId) => { if (userId == null) { // null or undefined: check current user return (g.user?.isGM ?? false); } // Explicit userId (including "0" or ""): look up in users map return (g.users?.get(userId)?.isGM ?? false); }, /** @returns {object|null} */ current: () => g.user ?? null, /** * Gets a user flag for a specific user. * @param {string} userId - The user ID to get the flag for. * @param {string} scope - The flag scope/namespace. * @param {string} key - The flag key. * @returns {unknown|null} The flag value, or null if not found. */ getFlag: (userId, scope, key) => { const user = g.users?.get(userId); if (user && typeof user.getFlag === 'function') { return user.getFlag(scope, key) ?? null; } return null; }, /** * Sets a user flag for a specific user. * Note: In FoundryVTT, users can only set their own flags. GM can set flags for any user. * This method wraps the native user.setFlag() which returns a Promise. * @param {string} userId - The user ID to set the flag for. * @param {string} scope - The flag scope/namespace. * @param {string} key - The flag key. * @param {unknown} value - The flag value to set. * @returns {Promise|null} The promise from user.setFlag(), or null if user not found. */ setFlag: (userId, scope, key, value) => { const user = g.users?.get(userId); if (user && typeof user.setFlag === 'function') { return /** @type {Promise} */ (user.setFlag(scope, key, value)); } return null; }, /** * Convenience method to get a module-scoped flag. * @param {string} userId - The user ID to get the flag for. * @param {string} key - The flag key (will be scoped to FoundryAdapter.FLAG_SCOPE). * @returns {unknown|null} The flag value, or null if not found. */ getFlagModule: (userId, key) => { const user = g.users?.get(userId); if (user && typeof user.getFlag === 'function') { return user.getFlag(FoundryAdapter.FLAG_SCOPE, key) ?? null; } return null; }, /** * Convenience method to set a module-scoped flag. * @param {string} userId - The user ID to set the flag for. * @param {string} key - The flag key (will be scoped to FoundryAdapter.FLAG_SCOPE). * @param {unknown} value - The flag value to set. * @returns {Promise|null} The promise from user.setFlag(), or null if user not found. */ setFlagModule: (userId, key, value) => { const user = g.users?.get(userId); if (user && typeof user.setFlag === 'function') { return /** @type {Promise} */ (user.setFlag(FoundryAdapter.FLAG_SCOPE, key, value)); } return null; }, }; /** Scenes surface — wraps game.scenes. */ this.scenes = { /** * Get the currently active scene. * @returns {object|null} The active scene or null. */ current: () => g.scenes?.active ?? null, /** * Get a scene by ID. * @param {string} id - The scene ID. * @returns {object|null} The scene object or null. */ get: (id) => g.scenes?.get(id) ?? null, }; /** * Notifications surface — wraps ui.notifications. * Errors are caught silently — notification failures must never crash visibility logic. */ this.notifications = { /** * Show an info notification. * @param {string} msg - The message to display. * @returns {void} */ info: (msg) => { try { ui.notifications?.info(msg); } catch (err) { console.warn('[ScryingPool] notifications.info:', err); } }, /** * Show a warning notification. * @param {string} msg - The message to display. * @returns {void} */ warn: (msg) => { try { ui.notifications?.warn(msg); } catch (err) { console.warn('[ScryingPool] notifications.warn:', err); } }, /** * Show an error notification. * @param {string} msg - The message to display. * @returns {void} */ error: (msg) => { try { ui.notifications?.error(msg); } catch (err) { console.warn('[ScryingPool] notifications.error:', err); } }, }; /** Hooks surface — wraps FoundryVTT Hooks global. */ this.hooks = { /** * Subscribe to a hook event. * @param {string} event - The event name. * @param {(...args: unknown[]) => void} handler - The event handler. * @returns {void|undefined} */ on: (event, handler) => { if (typeof Hooks !== 'undefined') return Hooks.on(event, handler); }, /** * Subscribe to a hook event once. * @param {string} event - The event name. * @param {(...args: unknown[]) => void} handler - The event handler. * @returns {void|undefined} */ once: (event, handler) => { if (typeof Hooks !== 'undefined') return Hooks.once(event, handler); }, /** * Unsubscribe from a hook event. * @param {string} event - The event name. * @param {(...args: unknown[]) => void} handler - The event handler. * @returns {void|undefined} */ off: (event, handler) => { if (typeof Hooks !== 'undefined') return Hooks.off(event, handler); }, /** * Call all handlers for a hook event. * @param {string} event - The event name. * @param {...unknown[]} args - The event arguments. * @returns {boolean|undefined} */ callAll: (event, ...args) => { if (typeof Hooks !== 'undefined') return Hooks.callAll(event, ...args); return false; }, }; /** i18n surface — wraps game.i18n for localization. */ this.i18n = { /** * Localize a string using the module's translation keys. * @param {string} key - The translation key (e.g., 'video-view-manager.notifications.gmHid') * @param {object} [data] - Optional data for string interpolation * @returns {string} The localized string */ localize: (key, data) => { if (g.i18n && typeof g.i18n.localize === 'function') { return g.i18n.localize(key, data); } return key; // Fallback: return the key if i18n not available }, }; } /** * Probes the game.webrtc (AVMaster) instance for WebRTC capability. * * Probe logic and results (FoundryVTT v14, 2026-05-21): * - If game.webrtc is null/falsy → AV disabled or not yet initialised → `'unsupported'` * - If game.webrtc is not an object → invalid type → `'unsupported'` * - If game.webrtc.client lacks getMediaStreamForUser() → non-standard backend → `'unsupported'` * - Otherwise: getMediaStreamForUser() is available for stream access → `'stream-access'` * * NOTE: For FULL REPLACEMENT architecture (hiding Foundry's AV dock and showing our own): * - We use 'stream-access' mode to get actual MediaStream objects via getMediaStreamForUser() * - We create our own