Files
scrying-pool/src/foundry/FoundryAdapter.js
T
2026-05-24 09:39:53 +02:00

538 lines
20 KiB
JavaScript

// 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<unknown>}
*/
set: (key, value) => /** @type {Promise<unknown>} */ (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<unknown>|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<unknown>} */ (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<unknown>|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<unknown>} */ (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 <video> elements and set srcObject to the stream
* - We hide Foundry's AV dock with CSS
*
* The 'track-disable' and 'css-fallback' modes from the original spike were for OVERLAY
* architecture (overlaying on Foundry's tiles). For REPLACEMENT, we need 'stream-access'.
*
* @param {unknown} gameWebrtc - The game.webrtc value at ready time (may be null/undefined)
* @returns {'stream-access'|'unsupported'}
*/
static probeCapability(gameWebrtc) {
if (!gameWebrtc || typeof gameWebrtc !== 'object') return 'unsupported';
const client = /** @type {any} */ (gameWebrtc).client;
if (!client || typeof client.getMediaStreamForUser !== 'function') return 'unsupported';
// Stream access is available - we can get MediaStream objects for all users
return 'stream-access';
}
/**
* Builds the webrtc surface object for the `'stream-access'` capability path.
* Provides full access to WebRTC streams for creating custom video tiles.
*
* NOTE: This is used for FULL REPLACEMENT architecture where we:
* 1. Hide Foundry's AV dock
* 2. Create our own video elements using getMediaStreamForUser()
* 3. Display actual video feeds in our ScryingPoolStrip
*
* @param {{ client: object }} gameWebrtc - The game.webrtc (AVMaster) instance
* @returns {object} WebRTC surface with stream access methods
* @throws {TypeError} if gameWebrtc or gameWebrtc.client is invalid
*/
static buildWebRTCSurface(gameWebrtc) {
if (!gameWebrtc || typeof gameWebrtc !== 'object' || !gameWebrtc.client) {
throw new TypeError('FoundryAdapter.buildWebRTCSurface: gameWebrtc must be an object with a client property');
}
const client = gameWebrtc.client;
return {
/**
* Get the MediaStream for a specific user.
* @param {string} userId - The user ID
* @returns {MediaStream|null} The MediaStream or null if not available
*/
getMediaStreamForUser: (userId) => {
try {
if (typeof userId !== 'string' || userId.trim() === '') {
console.warn('[ScryingPool] getMediaStreamForUser: invalid userId:', userId);
return null;
}
return client.getMediaStreamForUser?.(userId) ?? null;
} catch (err) {
console.error('[ScryingPool] getMediaStreamForUser failed:', err);
return null;
}
},
/**
* Get all connected user IDs.
* @returns {string[]} Array of connected user IDs
*/
getConnectedUsers: () => {
try {
return client.getConnectedUsers?.() ?? [];
} catch (err) {
console.error('[ScryingPool] getConnectedUsers failed:', err);
return [];
}
},
/**
* Get the levels stream for a user (for volume detection).
* @param {string} userId - The user ID
* @returns {MediaStream|null} The levels stream or null
*/
getLevelsStreamForUser: (userId) => {
try {
if (typeof userId !== 'string' || userId.trim() === '') {
console.warn('[ScryingPool] getLevelsStreamForUser: invalid userId:', userId);
return null;
}
return client.getLevelsStreamForUser?.(userId) ?? null;
} catch (err) {
console.error('[ScryingPool] getLevelsStreamForUser failed:', err);
return null;
}
},
/**
* Check if current user's audio is enabled.
* @returns {boolean}
*/
isAudioEnabled: () => {
try {
return client.isAudioEnabled?.() ?? false;
} catch (err) {
console.error('[ScryingPool] isAudioEnabled failed:', err);
return false;
}
},
/**
* Check if current user's video is enabled.
* @returns {boolean}
*/
isVideoEnabled: () => {
try {
return client.isVideoEnabled?.() ?? false;
} catch (err) {
console.error('[ScryingPool] isVideoEnabled failed:', err);
return false;
}
},
/**
* Enable or disable current user's audio.
* @param {boolean} enable - Whether to enable audio
*/
toggleAudio: (enable) => {
try {
if (typeof client.toggleAudio === 'function') {
client.toggleAudio(enable);
}
} catch (err) {
console.error('[ScryingPool] toggleAudio failed:', err);
}
},
/**
* Enable or disable current user's video.
* @param {boolean} enable - Whether to enable video
*/
toggleVideo: (enable) => {
try {
if (typeof client.toggleVideo === 'function') {
client.toggleVideo(enable);
}
} catch (err) {
console.error('[ScryingPool] toggleVideo failed:', err);
}
},
/**
* Enable or disable current user's broadcast.
* @param {boolean} enable - Whether to enable broadcast
*/
toggleBroadcast: (enable) => {
try {
if (typeof client.toggleBroadcast === 'function') {
client.toggleBroadcast(enable);
}
} catch (err) {
console.error('[ScryingPool] toggleBroadcast failed:', err);
}
},
/**
* Set a video element for a user with their stream.
* @param {string} userId - The user ID
* @param {HTMLVideoElement} videoElement - The video element to set
* @returns {Promise<void>}
*/
setUserVideo: async (userId, videoElement) => {
try {
if (typeof userId !== 'string' || userId.trim() === '') {
console.warn('[ScryingPool] setUserVideo: invalid userId:', userId);
return;
}
if (!(videoElement instanceof HTMLVideoElement)) {
console.warn('[ScryingPool] setUserVideo: videoElement is not a HTMLVideoElement');
return;
}
if (typeof client.setUserVideo === 'function') {
await client.setUserVideo(userId, videoElement);
}
} catch (err) {
console.error('[ScryingPool] setUserVideo failed:', err);
}
},
// Legacy: disable video track (cosmetic only, doesn't reduce bandwidth)
disableTrack: (userId) => {
try {
if (typeof userId !== 'string' || userId.trim() === '') {
console.warn('[ScryingPool] disableTrack: invalid userId:', userId);
return;
}
const stream = client.getMediaStreamForUser?.(userId);
const tracks = stream?.getVideoTracks() ?? [];
for (const track of tracks) track.enabled = false;
if (tracks.length === 0) {
console.warn('[ScryingPool] disableTrack: no video tracks found for', userId);
}
} catch (err) {
console.error('[ScryingPool] disableTrack failed:', err);
}
},
// Legacy: enable video track
enableTrack: (userId) => {
try {
if (typeof userId !== 'string' || userId.trim() === '') {
console.warn('[ScryingPool] enableTrack: invalid userId:', userId);
return;
}
const stream = client.getMediaStreamForUser?.(userId);
const tracks = stream?.getVideoTracks() ?? [];
for (const track of tracks) track.enabled = true;
if (tracks.length === 0) {
console.warn('[ScryingPool] enableTrack: no video tracks found for', userId);
}
} catch (err) {
console.error('[ScryingPool] enableTrack failed:', err);
}
},
};
}
}