CLose story 1.2
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* PendingOp contract.
|
||||
*
|
||||
* A PendingOp tracks an in-flight visibility state change from the moment
|
||||
* the GM issues the intent until the authoritative echo is received (or
|
||||
* the 3-second timeout fires and triggers a revert).
|
||||
*
|
||||
* Lifecycle:
|
||||
* create → register in Map<opId, PendingOp>
|
||||
* echo received → delete from map + clearTimeout(timeoutId)
|
||||
* 3s timeout fires → revert to previousState + GM notification
|
||||
*
|
||||
* @module contracts/pending-op
|
||||
*/
|
||||
|
||||
/** @typedef {Object} PendingOp */
|
||||
|
||||
/** Shape version constant for PendingOp. @type {1} */
|
||||
export const PENDING_OP_VERSION = 1;
|
||||
|
||||
/**
|
||||
* @typedef {Object} PendingOp
|
||||
* @property {string} opId - Unique operation identifier (non-empty string).
|
||||
* @property {string} userId - Target participant userId (non-empty string).
|
||||
* @property {string} targetState - Desired VisibilityState (non-empty string).
|
||||
* @property {string} previousState - State before this op; used for revert (non-empty string).
|
||||
* @property {number} issuedAt - Timestamp (ms) when op was issued — Date.now() integer.
|
||||
* @property {number|null} timeoutId - setTimeout handle; null if timeout not yet set.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a new PendingOp.
|
||||
* @param {string} opId - Unique operation identifier.
|
||||
* @param {string} userId - Target participant userId.
|
||||
* @param {string} targetState - Desired VisibilityState.
|
||||
* @param {string} previousState - State before this op.
|
||||
* @param {number} [issuedAt] - Timestamp; defaults to Date.now().
|
||||
* @returns {PendingOp}
|
||||
*/
|
||||
export function createPendingOp(opId, userId, targetState, previousState, issuedAt = Date.now()) {
|
||||
return { opId, userId, targetState, previousState, issuedAt, timeoutId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a PendingOp DTO. Throws TypeError on any violation.
|
||||
* @param {unknown} data - Value to validate.
|
||||
* @returns {PendingOp} The validated PendingOp.
|
||||
* @throws {TypeError} If data fails validation.
|
||||
*/
|
||||
export function isValidPendingOp(data) {
|
||||
if (data === null || typeof data !== "object") {
|
||||
throw new TypeError("PendingOp: must be an object");
|
||||
}
|
||||
const obj = /** @type {Record<string, unknown>} */ (data);
|
||||
const { opId, userId, targetState, previousState, issuedAt, timeoutId, ...rest } = obj;
|
||||
if (Object.keys(rest).length > 0) {
|
||||
throw new TypeError(`PendingOp: unknown keys: ${Object.keys(rest).join(", ")}`);
|
||||
}
|
||||
if (typeof opId !== "string" || opId.trim().length === 0) {
|
||||
throw new TypeError("PendingOp: opId must be a non-empty string");
|
||||
}
|
||||
if (typeof userId !== "string" || userId.trim().length === 0) {
|
||||
throw new TypeError("PendingOp: userId must be a non-empty string");
|
||||
}
|
||||
if (typeof targetState !== "string" || targetState.trim().length === 0) {
|
||||
throw new TypeError("PendingOp: targetState must be a non-empty string");
|
||||
}
|
||||
if (typeof previousState !== "string" || previousState.trim().length === 0) {
|
||||
throw new TypeError("PendingOp: previousState must be a non-empty string");
|
||||
}
|
||||
if (typeof issuedAt !== "number" || !Number.isFinite(issuedAt) || issuedAt < 0 || !Number.isInteger(issuedAt)) {
|
||||
throw new TypeError("PendingOp: issuedAt must be a finite non-negative integer");
|
||||
}
|
||||
if (timeoutId !== null) {
|
||||
if (typeof timeoutId !== "number") {
|
||||
throw new TypeError("PendingOp: timeoutId must be a number or null");
|
||||
}
|
||||
if (!Number.isFinite(timeoutId) || timeoutId < 0) {
|
||||
throw new TypeError("PendingOp: timeoutId must be a finite non-negative number");
|
||||
}
|
||||
}
|
||||
return /** @type {PendingOp} */ (data);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Scene Preset contract.
|
||||
*
|
||||
* A Scene Preset is a named snapshot of the Visibility Matrix, stored as a
|
||||
* flag on a FoundryVTT Scene document. Up to 50 presets per world.
|
||||
*
|
||||
* Storage key: scene.getFlag('video-view-manager', 'preset')
|
||||
* Shape: { _version: 1, presets: { [name: string]: ScenePreset } }
|
||||
*
|
||||
* @module contracts/scene-preset
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ScenePreset
|
||||
* @property {1} _version - Schema version; always 1 for v1.
|
||||
* @property {string} name - Unique preset name (non-empty string).
|
||||
* @property {Object.<string, string>} matrix - userId→VisibilityState snapshot.
|
||||
* @property {number} createdAt - Timestamp (ms) when preset was created.
|
||||
* @property {number} updatedAt - Timestamp (ms) when preset was last updated.
|
||||
*/
|
||||
|
||||
export const SCENE_PRESET_VERSION = 1;
|
||||
export const MAX_PRESETS_PER_WORLD = 50;
|
||||
|
||||
/**
|
||||
* Creates a new ScenePreset.
|
||||
* @param {string} name - Unique preset name.
|
||||
* @param {Object.<string, string>} [matrix={}] - userId→VisibilityState snapshot.
|
||||
* @param {number} [now] - Timestamp; defaults to Date.now().
|
||||
* @returns {ScenePreset}
|
||||
*/
|
||||
export function createScenePreset(name, matrix = {}, now = Date.now()) {
|
||||
return {
|
||||
_version: SCENE_PRESET_VERSION,
|
||||
name,
|
||||
matrix: { ...matrix },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a ScenePreset DTO. Throws TypeError on any violation.
|
||||
* @param {unknown} data - Value to validate.
|
||||
* @returns {ScenePreset} The validated preset.
|
||||
* @throws {TypeError} If data fails validation.
|
||||
*/
|
||||
export function isValidScenePreset(data) {
|
||||
if (data === null || typeof data !== "object") {
|
||||
throw new TypeError("ScenePreset: must be an object");
|
||||
}
|
||||
const obj = /** @type {Record<string, unknown>} */ (data);
|
||||
const { _version, name, matrix, createdAt, updatedAt, ...rest } = obj;
|
||||
if (Object.keys(rest).length > 0) {
|
||||
throw new TypeError(`ScenePreset: unknown keys: ${Object.keys(rest).join(", ")}`);
|
||||
}
|
||||
if (_version !== SCENE_PRESET_VERSION) {
|
||||
throw new TypeError(`ScenePreset: _version must be ${SCENE_PRESET_VERSION}, got ${_version}`);
|
||||
}
|
||||
if (typeof name !== "string" || name.length === 0) {
|
||||
throw new TypeError("ScenePreset: name must be a non-empty string");
|
||||
}
|
||||
if (matrix === null || typeof matrix !== "object" || Array.isArray(matrix)) {
|
||||
throw new TypeError("ScenePreset: matrix must be a plain object");
|
||||
}
|
||||
const matrixObj = /** @type {Record<string, unknown>} */ (matrix);
|
||||
for (const [userId, state] of Object.entries(matrixObj)) {
|
||||
if (typeof userId !== "string" || userId.length === 0) {
|
||||
throw new TypeError(`ScenePreset: userId key must be non-empty string, got "${userId}"`);
|
||||
}
|
||||
if (typeof state !== "string" || state.length === 0) {
|
||||
throw new TypeError(`ScenePreset: state for "${userId}" must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
if (typeof createdAt !== "number" || !Number.isFinite(createdAt) || createdAt < 0) {
|
||||
throw new TypeError("ScenePreset: createdAt must be a finite non-negative integer");
|
||||
}
|
||||
if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt) || updatedAt < 0) {
|
||||
throw new TypeError("ScenePreset: updatedAt must be a finite non-negative integer");
|
||||
}
|
||||
return /** @type {ScenePreset} */ (data);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Socket message contract.
|
||||
*
|
||||
* Two message directions:
|
||||
* Intent (GM → all): scrying-pool.visibility.set { opId, userId, targetState }
|
||||
* Echo (all ← GM): scrying-pool.visibility.updated { opId, userId, state, revision }
|
||||
*
|
||||
* Validated at both send and receive. Payload ≥ 4096 bytes → throw before emit.
|
||||
*
|
||||
* @module contracts/socket-message
|
||||
*/
|
||||
|
||||
/** @typedef {'scrying-pool.visibility.set'|'scrying-pool.visibility.updated'|'scrying-pool.preset.apply'|'scrying-pool.preset.applied'} SocketEventName */
|
||||
|
||||
/**
|
||||
* @typedef {Object} SocketIntentPayload
|
||||
* @property {string} opId - Unique operation ID (non-empty string).
|
||||
* @property {string} userId - Target participant userId (non-empty string).
|
||||
* @property {string} targetState - Desired VisibilityState.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SocketEchoPayload
|
||||
* @property {string} opId - Matches the originating intent opId.
|
||||
* @property {string} userId - Affected participant userId.
|
||||
* @property {string} state - Authoritative VisibilityState after this operation.
|
||||
* @property {number} revision - Monotonically increasing revision counter.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SocketMessage
|
||||
* @property {SocketEventName} event - Socket event name.
|
||||
* @property {SocketIntentPayload|SocketEchoPayload} payload - Message payload.
|
||||
*/
|
||||
|
||||
export const SOCKET_EVENTS = Object.freeze({
|
||||
VISIBILITY_SET: "scrying-pool.visibility.set",
|
||||
VISIBILITY_UPDATED: "scrying-pool.visibility.updated",
|
||||
PRESET_APPLY: "scrying-pool.preset.apply",
|
||||
PRESET_APPLIED: "scrying-pool.preset.applied",
|
||||
});
|
||||
|
||||
export const MAX_PAYLOAD_BYTES = 4096;
|
||||
|
||||
/**
|
||||
* Creates a socket intent message (GM → all clients).
|
||||
* @param {string} opId - Unique operation ID.
|
||||
* @param {string} userId - Target participant userId.
|
||||
* @param {string} targetState - Desired VisibilityState.
|
||||
* @returns {SocketMessage}
|
||||
*/
|
||||
export function createSocketIntentMessage(opId, userId, targetState) {
|
||||
return {
|
||||
event: SOCKET_EVENTS.VISIBILITY_SET,
|
||||
payload: { opId, userId, targetState },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a socket echo message (authoritative broadcast to all clients).
|
||||
* @param {string} opId - Originating intent opId.
|
||||
* @param {string} userId - Affected participant userId.
|
||||
* @param {string} state - Authoritative VisibilityState.
|
||||
* @param {number} revision - Revision counter.
|
||||
* @returns {SocketMessage}
|
||||
*/
|
||||
export function createSocketEchoMessage(opId, userId, state, revision) {
|
||||
return {
|
||||
event: SOCKET_EVENTS.VISIBILITY_UPDATED,
|
||||
payload: { opId, userId, state, revision },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a SocketMessage DTO. Throws TypeError on any violation.
|
||||
* @param {unknown} data - Value to validate.
|
||||
* @returns {SocketMessage} The validated message.
|
||||
* @throws {TypeError} If data fails validation.
|
||||
*/
|
||||
export function isValidSocketMessage(data) {
|
||||
if (data === null || typeof data !== "object") {
|
||||
throw new TypeError("SocketMessage: must be an object");
|
||||
}
|
||||
const obj = /** @type {Record<string, unknown>} */ (data);
|
||||
const { event, payload, ...rest } = obj;
|
||||
if (Object.keys(rest).length > 0) {
|
||||
throw new TypeError(`SocketMessage: unknown keys: ${Object.keys(rest).join(", ")}`);
|
||||
}
|
||||
if (!Object.values(SOCKET_EVENTS).includes(/** @type {any} */ (event))) {
|
||||
throw new TypeError(`SocketMessage: unknown event "${event}"`);
|
||||
}
|
||||
if (payload === null || typeof payload !== "object") {
|
||||
throw new TypeError("SocketMessage: payload must be an object");
|
||||
}
|
||||
const p = /** @type {Record<string, unknown>} */ (payload);
|
||||
// Validate intent payload
|
||||
if (event === SOCKET_EVENTS.VISIBILITY_SET) {
|
||||
const { opId, userId, targetState, ...payloadRest } = p;
|
||||
if (Object.keys(payloadRest).length > 0) {
|
||||
throw new TypeError(`SocketMessage intent: unknown payload keys: ${Object.keys(payloadRest).join(", ")}`);
|
||||
}
|
||||
if (typeof opId !== "string" || opId.length === 0) {
|
||||
throw new TypeError("SocketMessage: opId must be a non-empty string");
|
||||
}
|
||||
if (typeof userId !== "string" || userId.length === 0) {
|
||||
throw new TypeError("SocketMessage: userId must be a non-empty string");
|
||||
}
|
||||
if (typeof targetState !== "string" || targetState.length === 0) {
|
||||
throw new TypeError("SocketMessage: targetState must be a non-empty string");
|
||||
}
|
||||
}
|
||||
// Validate echo payload
|
||||
if (event === SOCKET_EVENTS.VISIBILITY_UPDATED) {
|
||||
const { opId, userId, state, revision, ...payloadRest } = p;
|
||||
if (Object.keys(payloadRest).length > 0) {
|
||||
throw new TypeError(`SocketMessage echo: unknown payload keys: ${Object.keys(payloadRest).join(", ")}`);
|
||||
}
|
||||
if (typeof opId !== "string" || opId.length === 0) {
|
||||
throw new TypeError("SocketMessage: opId must be a non-empty string");
|
||||
}
|
||||
if (typeof userId !== "string" || userId.length === 0) {
|
||||
throw new TypeError("SocketMessage: userId must be a non-empty string");
|
||||
}
|
||||
if (typeof state !== "string" || state.length === 0) {
|
||||
throw new TypeError("SocketMessage: state must be a non-empty string");
|
||||
}
|
||||
if (typeof revision !== "number" || !Number.isFinite(revision) || revision < 0) {
|
||||
throw new TypeError("SocketMessage: revision must be a finite non-negative number");
|
||||
}
|
||||
}
|
||||
return /** @type {SocketMessage} */ (data);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Visibility Matrix contract.
|
||||
*
|
||||
* Canonical shape: { _version: 1, matrix: { [userId: string]: VisibilityState } }
|
||||
* where VisibilityState ∈ VISIBILITY_STATES.
|
||||
*
|
||||
* StateStore is the sole writer of this structure. All other modules treat it as read-only.
|
||||
*
|
||||
* @module contracts/visibility-matrix
|
||||
*/
|
||||
|
||||
/** @typedef {'active'|'hidden'|'self-muted'|'offline'|'cam-lost'|'reconnecting'|'never-connected'|'ghost'} VisibilityState */
|
||||
|
||||
/**
|
||||
* @typedef {Object} VisibilityMatrix
|
||||
* @property {1} _version - Schema version; always 1 for v1.
|
||||
* @property {Object.<string, VisibilityState>} matrix - userId → VisibilityState map.
|
||||
*/
|
||||
|
||||
export const VISIBILITY_STATES = Object.freeze([
|
||||
"active",
|
||||
"hidden",
|
||||
"self-muted",
|
||||
"offline",
|
||||
"cam-lost",
|
||||
"reconnecting",
|
||||
"never-connected",
|
||||
"ghost",
|
||||
]);
|
||||
|
||||
export const VISIBILITY_MATRIX_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Creates a new VisibilityMatrix with an optional initial matrix.
|
||||
* @param {Object.<string, VisibilityState>} [matrix={}] - Initial userId→state entries.
|
||||
* @returns {VisibilityMatrix}
|
||||
*/
|
||||
export function createVisibilityMatrix(matrix = {}) {
|
||||
return { _version: VISIBILITY_MATRIX_VERSION, matrix: { ...matrix } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a VisibilityMatrix DTO. Throws TypeError on any violation.
|
||||
* @param {unknown} data - Value to validate.
|
||||
* @returns {VisibilityMatrix} The validated matrix.
|
||||
* @throws {TypeError} If data fails validation.
|
||||
*/
|
||||
export function isValidVisibilityMatrix(data) {
|
||||
if (data === null || typeof data !== "object") {
|
||||
throw new TypeError("VisibilityMatrix: must be an object");
|
||||
}
|
||||
const obj = /** @type {Record<string, unknown>} */ (data);
|
||||
const { _version, matrix, ...rest } = obj;
|
||||
if (Object.keys(rest).length > 0) {
|
||||
throw new TypeError(`VisibilityMatrix: unknown keys: ${Object.keys(rest).join(", ")}`);
|
||||
}
|
||||
if (_version !== VISIBILITY_MATRIX_VERSION) {
|
||||
throw new TypeError(`VisibilityMatrix: _version must be ${VISIBILITY_MATRIX_VERSION}, got ${_version}`);
|
||||
}
|
||||
if (matrix === null || typeof matrix !== "object" || Array.isArray(matrix)) {
|
||||
throw new TypeError("VisibilityMatrix: matrix must be a plain object");
|
||||
}
|
||||
const matrixObj = /** @type {Record<string, unknown>} */ (matrix);
|
||||
for (const [userId, state] of Object.entries(matrixObj)) {
|
||||
if (typeof userId !== "string" || userId.length === 0) {
|
||||
throw new TypeError(`VisibilityMatrix: userId must be a non-empty string, got "${userId}"`);
|
||||
}
|
||||
if (!VISIBILITY_STATES.includes(/** @type {any} */ (state))) {
|
||||
throw new TypeError(`VisibilityMatrix: invalid state "${state}" for userId "${userId}"`);
|
||||
}
|
||||
}
|
||||
return /** @type {VisibilityMatrix} */ (data);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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.
|
||||
*
|
||||
* Feature-detects WebRTC track-disabling capability at init time via
|
||||
* {@link FoundryAdapter.probeCapability}. Exposes a `webrtc` surface
|
||||
* ({disableTrack, enableTrack}) if track-disable is confirmed; `null` otherwise.
|
||||
*
|
||||
* Construction is side-effect-free. The webrtc property must be set by the
|
||||
* caller (at Hooks.once('ready') when game.webrtc is available) via
|
||||
* {@link FoundryAdapter.buildWebRTCSurface}. Story 1.3 wires this up.
|
||||
*/
|
||||
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';
|
||||
|
||||
/**
|
||||
* Creates a FoundryAdapter. Side-effect-free — no game.* access in constructor.
|
||||
*/
|
||||
constructor() {
|
||||
/**
|
||||
* WebRTC track-disabling surface, or null on the css-fallback/unsupported path.
|
||||
*
|
||||
* Set to `{ disableTrack, enableTrack }` only if probeCapability returns
|
||||
* `'track-disable'`. As of FoundryVTT v14, the probe always returns
|
||||
* `'css-fallback'` or `'unsupported'` — see OQ-1 spike comment at top of file.
|
||||
*
|
||||
* @type {{ disableTrack(userId: string): void, enableTrack(userId: string): void } | null}
|
||||
*/
|
||||
this.webrtc = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes the game.webrtc (AVMaster) instance for WebRTC track-disabling 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.client lacks getMediaStreamForUser() → non-standard backend → `'unsupported'`
|
||||
* - Otherwise: tracks are technically reachable but track.enabled = false does NOT reduce
|
||||
* inbound WebRTC bandwidth (RTP packets keep arriving). The `'track-disable'` outcome
|
||||
* requires true bandwidth elimination, so the result is `'css-fallback'`.
|
||||
*
|
||||
* The `'track-disable'` branch in buildWebRTCSurface is kept for forward compatibility
|
||||
* in case a future FoundryVTT version or custom AVClient exposes real bandwidth control.
|
||||
*
|
||||
* @param {unknown} gameWebrtc - The game.webrtc value at ready time (may be null/undefined)
|
||||
* @returns {'track-disable'|'css-fallback'|'unsupported'}
|
||||
*/
|
||||
static probeCapability(gameWebrtc) {
|
||||
if (!gameWebrtc) return 'unsupported';
|
||||
const client = /** @type {any} */ (gameWebrtc).client;
|
||||
if (!client || typeof client.getMediaStreamForUser !== 'function') return 'unsupported';
|
||||
// track.enabled = false on remote inbound tracks does NOT stop WebRTC bandwidth.
|
||||
// The 'track-disable' branch is unreachable with the current FoundryVTT v14 API.
|
||||
return 'css-fallback';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the webrtc surface object for the `'track-disable'` capability path.
|
||||
*
|
||||
* NOTE: As of FoundryVTT v14, {@link FoundryAdapter.probeCapability} never returns
|
||||
* `'track-disable'` because `track.enabled = false` does not stop inbound RTP bandwidth.
|
||||
* This method is kept for forward compatibility and as tested documentation of the
|
||||
* interface contract that Story 1.3+ consumers expect.
|
||||
*
|
||||
* @param {{ client: { getMediaStreamForUser(userId: string): (MediaStream|null|undefined) } }} gameWebrtc
|
||||
* @returns {{ disableTrack(userId: string): void, enableTrack(userId: string): void }}
|
||||
*/
|
||||
static buildWebRTCSurface(gameWebrtc) {
|
||||
return {
|
||||
disableTrack(userId) {
|
||||
try {
|
||||
const stream = gameWebrtc.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);
|
||||
}
|
||||
},
|
||||
enableTrack(userId) {
|
||||
try {
|
||||
const stream = gameWebrtc.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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Minimal FoundryVTT global type declarations for type-checking the module entry point.
|
||||
* The `Hooks` object is provided by FoundryVTT at runtime as a browser global.
|
||||
*/
|
||||
|
||||
declare const Hooks: {
|
||||
once(event: string, fn: (...args: unknown[]) => void): void;
|
||||
on(event: string, fn: (...args: unknown[]) => void): void;
|
||||
off(event: string, fn: (...args: unknown[]) => void): void;
|
||||
call(event: string, ...args: unknown[]): boolean;
|
||||
callAll(event: string, ...args: unknown[]): boolean;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Generates a unique operation ID for PendingOp tracking.
|
||||
* @returns {string} A unique identifier string.
|
||||
*/
|
||||
export function generateOpId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user