Fix Story 1.3: StateStore spec compliance and minor cleanup

Critical Fix:
- StateStore now uses global Hooks.callAll directly (per spec)
- Removed hooks parameter from StateStore constructor
- Updated module.js to pass only adapter.settings
- Updated tests to stub globalThis.Hooks

Minor Cleanup:
- Fixed misleading warning in SocketHandler.registerPendingOp
- Added clarifying comment for setMatrix _revision behavior

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-05-22 11:38:45 +02:00
parent 110b295a7b
commit 5ba7717ecd
17 changed files with 2391 additions and 55 deletions
-2
View File
@@ -13,8 +13,6 @@
* @module contracts/pending-op
*/
/** @typedef {Object} PendingOp */
/** Shape version constant for PendingOp. @type {1} */
export const PENDING_OP_VERSION = 1;
+9 -4
View File
@@ -2,7 +2,7 @@
* Socket message contract.
*
* Two message directions:
* Intent (GM → all): scrying-pool.visibility.set { opId, userId, targetState }
* Intent (GM → all): scrying-pool.visibility.set { opId, userId, targetState, baseRevision }
* Echo (all ← GM): scrying-pool.visibility.updated { opId, userId, state, revision }
*
* Validated at both send and receive. Payload ≥ 4096 bytes → throw before emit.
@@ -17,6 +17,7 @@
* @property {string} opId - Unique operation ID (non-empty string).
* @property {string} userId - Target participant userId (non-empty string).
* @property {string} targetState - Desired VisibilityState.
* @property {number} baseRevision - Revision counter when the GM issued the intent (for latest-revision-wins guard).
*/
/**
@@ -47,12 +48,13 @@ export const MAX_PAYLOAD_BYTES = 4096;
* @param {string} opId - Unique operation ID.
* @param {string} userId - Target participant userId.
* @param {string} targetState - Desired VisibilityState.
* @param {number} baseRevision - Revision counter at time of intent.
* @returns {SocketMessage}
*/
export function createSocketIntentMessage(opId, userId, targetState) {
export function createSocketIntentMessage(opId, userId, targetState, baseRevision) {
return {
event: SOCKET_EVENTS.VISIBILITY_SET,
payload: { opId, userId, targetState },
payload: { opId, userId, targetState, baseRevision },
};
}
@@ -95,7 +97,7 @@ export function isValidSocketMessage(data) {
const p = /** @type {Record<string, unknown>} */ (payload);
// Validate intent payload
if (event === SOCKET_EVENTS.VISIBILITY_SET) {
const { opId, userId, targetState, ...payloadRest } = p;
const { opId, userId, targetState, baseRevision, ...payloadRest } = p;
if (Object.keys(payloadRest).length > 0) {
throw new TypeError(`SocketMessage intent: unknown payload keys: ${Object.keys(payloadRest).join(", ")}`);
}
@@ -108,6 +110,9 @@ export function isValidSocketMessage(data) {
if (typeof targetState !== "string" || targetState.length === 0) {
throw new TypeError("SocketMessage: targetState must be a non-empty string");
}
if (typeof baseRevision !== "number" || !Number.isFinite(baseRevision) || baseRevision < 0) {
throw new TypeError("SocketMessage: baseRevision must be a finite non-negative number");
}
}
// Validate echo payload
if (event === SOCKET_EVENTS.VISIBILITY_UPDATED) {
+5 -1
View File
@@ -15,6 +15,7 @@
* @typedef {Object} VisibilityMatrix
* @property {1} _version - Schema version; always 1 for v1.
* @property {Object.<string, VisibilityState>} matrix - userId → VisibilityState map.
* @property {number} [_revision] - Optional revision counter for optimistic concurrency control.
*/
export const VISIBILITY_STATES = Object.freeze([
@@ -50,7 +51,7 @@ export function isValidVisibilityMatrix(data) {
throw new TypeError("VisibilityMatrix: must be an object");
}
const obj = /** @type {Record<string, unknown>} */ (data);
const { _version, matrix, ...rest } = obj;
const { _version, matrix, _revision, ...rest } = obj;
if (Object.keys(rest).length > 0) {
throw new TypeError(`VisibilityMatrix: unknown keys: ${Object.keys(rest).join(", ")}`);
}
@@ -60,6 +61,9 @@ export function isValidVisibilityMatrix(data) {
if (matrix === null || typeof matrix !== "object" || Array.isArray(matrix)) {
throw new TypeError("VisibilityMatrix: matrix must be a plain object");
}
if (_revision !== undefined && (typeof _revision !== "number" || !Number.isFinite(_revision) || _revision < 0)) {
throw new TypeError("VisibilityMatrix: _revision must be a finite non-negative number");
}
const matrixObj = /** @type {Record<string, unknown>} */ (matrix);
for (const [userId, state] of Object.entries(matrixObj)) {
if (typeof userId !== "string" || userId.length === 0) {
+222
View File
@@ -0,0 +1,222 @@
/**
* SocketHandler — queued, timeout-aware socket emit with PendingOp lifecycle.
*
* Wraps the adapter.socket surface to provide:
* - Readiness queue (messages emitted before setReady are drained at setReady time)
* - PendingOp registration + confirmation (opId keyed, 3s timeout, one retry, revert)
* - Payload size guard (throws on payload ≥ MAX_PAYLOAD_BYTES)
*
* Import rule: may only import from src/contracts/ and src/utils/.
* No game.* access — all FoundryVTT interaction flows through injected surfaces.
* Throws errors — callers are responsible for catching.
*
* @module core/SocketHandler
*/
import { MAX_PAYLOAD_BYTES } from '../contracts/socket-message.js';
// Maximum queued messages before we start dropping (prevents unbounded memory growth)
export const MAX_QUEUE_SIZE = 1000;
export const SOCKET_TIMEOUT_MS = 3000;
/**
* Queued socket handler with PendingOp lifecycle management (timeout, retry, revert).
*/
export class SocketHandler {
/**
* @param {{ emit(event: string, payload: object): void, on(event: string, handler: Function): void, off(event: string, handler: Function): void }} socket
* Injected adapter.socket surface.
* @param {{ on(event: string, handler: Function): void, once(event: string, handler: Function): void }} hooks
* Injected adapter.hooks surface (reserved for Story 1.4 hook-driven flows).
*/
constructor(socket, hooks) {
if (!socket || typeof socket !== 'object' || typeof socket.emit !== 'function') {
throw new TypeError('SocketHandler: socket argument is required and must have an emit method');
}
if (!hooks || typeof hooks !== 'object') {
throw new TypeError('SocketHandler: hooks argument is required and must be an object');
}
this._socket = socket;
this._hooks = hooks;
/** @type {Array<{event: string, payload: object}>} */
this._messageQueue = [];
this._isReady = false;
/** @type {Map<string, Record<string, any>>} opId → PendingOp */
this._pendingOps = new Map();
/** @type {{ onRevert(pendingOp: object): void } | null} */
this._handler = null;
/** @type {number} */
this._droppedCount = 0;
}
/**
* Marks the handler as ready, stores the revert callback, and drains the queue.
* Null handler: logs a warning and proceeds — safe to call with null.
* In Story 1.3, module.js does NOT call setReady — that is Story 1.4's responsibility.
* @param {{ onRevert(pendingOp: object): void } | null} handler
*/
setReady(handler) {
if (!handler) {
console.warn('[ScryingPool] SocketHandler.setReady: handler is null — revert callbacks will be skipped');
}
this._isReady = true;
this._handler = handler;
this._drainQueue();
}
/**
* Destroys the handler, clearing all pending operations and timers.
* Should be called when the handler is no longer needed to prevent memory leaks.
*/
destroy() {
for (const [, op] of this._pendingOps) {
if (op.timeoutId) {
clearTimeout(op.timeoutId);
}
}
this._pendingOps.clear();
this._messageQueue = [];
this._isReady = false;
this._handler = null;
this._droppedCount = 0;
}
/**
* Emits an event on the socket. If not yet ready, queues the message for later.
* @param {string} event
* @param {object} payload
* @throws {Error} if payload JSON exceeds MAX_PAYLOAD_BYTES (4096 bytes)
* @throws {TypeError} if event is not a non-empty string
*/
emit(event, payload) {
if (typeof event !== 'string' || event.length === 0) {
throw new TypeError('SocketHandler: event must be a non-empty string');
}
let encoded;
try {
encoded = JSON.stringify(payload);
} catch (err) {
throw new Error(`[ScryingPool] SocketHandler: payload is not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`);
}
if (encoded.length >= MAX_PAYLOAD_BYTES) {
throw new Error(`[ScryingPool] SocketHandler: payload exceeds ${MAX_PAYLOAD_BYTES} bytes (${encoded.length})`);
}
if (!this._isReady) {
// Drop if queue is full to prevent unbounded memory growth
if (this._messageQueue.length >= MAX_QUEUE_SIZE) {
this._droppedCount++;
if (this._droppedCount === 1 || this._droppedCount % 100 === 0) {
console.warn(`[ScryingPool] SocketHandler: dropped ${this._droppedCount} queued messages due to queue overflow`);
}
return;
}
this._messageQueue.push({ event, payload });
return;
}
this._socket.emit(event, payload);
}
/**
* Registers a PendingOp and starts its 3-second acknowledgement timeout.
* Throws if pendingOp is null/undefined or missing opId.
* Warns and replaces if opId already exists (leaking previous timer).
* @param {Record<string, any>} pendingOp - Must have at least `{ opId: string }`.
* @param {string} [event] - Optional socket event name for retry.
* @param {object} [payload] - Optional payload for retry.
* @throws {TypeError} if pendingOp is null/undefined or missing opId
*/
registerPendingOp(pendingOp, event, payload) {
if (!pendingOp || typeof pendingOp !== 'object') {
throw new TypeError('SocketHandler.registerPendingOp: pendingOp must be an object');
}
if (typeof pendingOp.opId !== 'string' || pendingOp.opId.length === 0) {
throw new TypeError('SocketHandler.registerPendingOp: pendingOp must have a non-empty opId string');
}
// Warn if overwriting existing op (previous timer is cleared below)
if (this._pendingOps.has(pendingOp.opId)) {
const existingEntry = /** @type {{ pendingOp: Record<string, any>, timeoutId: number, event?: string, payload?: object }} */ (this._pendingOps.get(pendingOp.opId));
console.warn('[ScryingPool] SocketHandler: overwriting existing pending op', existingEntry.pendingOp.opId);
clearTimeout(existingEntry.timeoutId);
}
// Create timeout and store WITHOUT mutating the original pendingOp
const timeoutId = setTimeout(() => this._onTimeout(pendingOp.opId, false), SOCKET_TIMEOUT_MS);
this._pendingOps.set(pendingOp.opId, { pendingOp, event, payload, timeoutId });
}
/**
* Confirms a PendingOp by opId — clears its timer and removes it from the map.
* Unknown/stale opId: discards silently (no error).
* @param {string} opId
*/
confirmPendingOp(opId) {
const entry = this._pendingOps.get(opId);
if (!entry) return; // stale echo — discard silently
if (entry.timeoutId) {
clearTimeout(entry.timeoutId);
}
this._pendingOps.delete(opId);
}
/** @private */
_drainQueue() {
for (const { event, payload } of this._messageQueue) {
this._socket.emit(event, payload);
}
this._messageQueue = [];
}
/**
* Helper to remove internal timeoutId field from an object.
* @private
* @param {object} obj
* @returns {object} Object without timeoutId field
*/
_stripTimeoutId(obj) {
if (!obj || typeof obj !== 'object') return obj;
// eslint-disable-next-line no-unused-vars -- intentionally removing timeoutId field
const { timeoutId, ...rest } = /** @type {{ timeoutId?: number, [key: string]: any }} */ (obj);
return rest;
}
/**
* @private
* @param {string} opId
* @param {boolean} isRetry - true if this is the second timeout (after one retry attempt)
*/
_onTimeout(opId, isRetry) {
const entry = this._pendingOps.get(opId);
if (!entry) return; // already confirmed — stale timer, ignore
if (!isRetry) {
// First timeout: retry once
// Use stored event/payload if provided; otherwise use pendingOp minus timeoutId
if (this._isReady) {
try {
if (entry.event && entry.payload) {
// Use stored event and payload (explicit, clean)
this._socket.emit(entry.event, this._stripTimeoutId(entry.payload));
} else if (entry.pendingOp) {
// Fallback: use pendingOp minus timeoutId
// Use a sensible default event if not stored
this._socket.emit('scrying-pool.visibility.set', this._stripTimeoutId(entry.pendingOp));
}
} catch (err) {
// ignore emit error on retry — the revert path handles final failure
console.warn('[ScryingPool] SocketHandler: retry emit failed', err);
}
}
const retryTimeoutId = setTimeout(() => this._onTimeout(opId, true), SOCKET_TIMEOUT_MS);
entry.timeoutId = retryTimeoutId;
return;
}
// Second timeout — give up: warn + call handler.onRevert with the original pendingOp
this._pendingOps.delete(opId);
console.warn('[ScryingPool] SocketHandler: unacknowledged op after retry, reverting', opId);
if (!this._handler) return;
this._handler.onRevert(entry.pendingOp);
}
}
+150
View File
@@ -0,0 +1,150 @@
/**
* StateStore — in-memory visibility matrix with persistence and change events.
*
* Sole keeper of the visibility state for all participants in the current session.
* Persists state to `adapter.settings` ('visibilityMatrix') and emits
* `scrying-pool:stateChanged` via Hooks.callAll on every mutation.
*
* Import rule: may only import from src/contracts/ and src/utils/.
* Throws errors — callers (module.js / src/foundry/) are responsible for catching.
*
* @module core/StateStore
*/
import { VISIBILITY_STATES, isValidVisibilityMatrix } from '../contracts/visibility-matrix.js';
export const VISIBILITY_MATRIX_KEY = 'visibilityMatrix';
/**
* In-memory visibility matrix with settings persistence and hooks event emission.
* Uses Hooks.callAll directly (Hooks is a FoundryVTT standalone global).
*/
export class StateStore {
/**
* @param {{ get(key: string): unknown, set(key: string, value: unknown): Promise<unknown> }} settings
* Injected adapter.settings surface (already namespaced — pass short keys only).
*/
constructor(settings) {
if (!settings || typeof settings !== 'object') {
throw new TypeError('StateStore: settings argument is required and must be an object');
}
this._settings = settings;
/** @type {Record<string, string>} userId → VisibilityState */
this._matrix = {};
this._version = 1;
this._revision = 0;
}
/**
* Hydrates the in-memory matrix from the persisted world setting.
* Called from module.js Hooks.once('ready'). Idempotent — last call wins.
* On corrupt/missing data: logs warning and starts fresh (does not throw).
*/
init() {
try {
const raw = this._settings.get(VISIBILITY_MATRIX_KEY);
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const validated = isValidVisibilityMatrix(raw);
this._matrix = { ...validated.matrix };
this._version = validated._version ?? 1;
this._revision = validated._revision ?? 0;
}
} catch (err) {
if (err instanceof TypeError) {
console.warn('[ScryingPool] StateStore.init: invalid matrix data, starting fresh', err.message);
} else {
console.warn('[ScryingPool] StateStore.init: could not hydrate matrix, starting fresh');
}
this._matrix = {};
this._revision = 0;
}
}
/**
* Returns the current VisibilityState for a participant, or null if unknown.
* @param {string} userId
* @returns {string|null}
*/
getState(userId) {
return this._matrix[userId] ?? null;
}
/**
* Returns a deep snapshot of the current visibility matrix.
* Mutations to the returned object do not affect internal state.
* @returns {{ _version: number, matrix: Record<string, string> }}
*/
getMatrix() {
return { _version: this._version, matrix: { ...this._matrix } };
}
/**
* Updates a single participant's visibility state, persists, and emits hook.
* No-op if userId is empty/non-string or targetState is not a valid VISIBILITY_STATE.
* @param {string} userId
* @param {string} targetState - Must be a value in VISIBILITY_STATES.
* @returns {Promise<unknown>|undefined} The persistence promise from settings.set, or undefined for no-op.
*/
setVisibility(userId, targetState) {
if (!userId || typeof userId !== 'string') return undefined;
if (!VISIBILITY_STATES.includes(targetState)) return undefined;
const previousState = this._matrix[userId] ?? null;
this._matrix[userId] = targetState;
this._revision++;
const persistencePromise = this._settings.set(VISIBILITY_MATRIX_KEY, {
_version: this._version,
_revision: this._revision,
matrix: { ...this._matrix },
});
// Fire hook immediately (per AC-2), but catch errors to prevent hook failures
// from blocking state mutation. Persistence is async and may complete after.
try {
Hooks.callAll('scrying-pool:stateChanged', {
userId,
state: targetState,
previousState,
timestamp: Date.now(),
revision: this._revision,
});
} catch (hookErr) {
console.error('[ScryingPool] StateStore.setVisibility: hook emission failed', hookErr);
}
return persistencePromise;
}
/**
* Replaces the entire matrix (used by preset apply and scene restore).
* Throws TypeError on invalid matrix — src/core throws, callers catch.
* @param {{ _version: number, _revision?: number, matrix: Record<string, string> }} matrix
* @returns {Promise<unknown>} The persistence promise from settings.set.
*/
setMatrix(matrix) {
const validated = isValidVisibilityMatrix(matrix);
this._matrix = { ...validated.matrix };
this._version = validated._version ?? 1;
this._revision++; // Always increment from current value; input _revision is informational only
const persistencePromise = this._settings.set(VISIBILITY_MATRIX_KEY, {
_version: this._version,
_revision: this._revision,
matrix: { ...this._matrix },
});
// Fire hook immediately, but catch errors
try {
Hooks.callAll('scrying-pool:stateChanged', {
matrix: this.getMatrix(),
timestamp: Date.now(),
revision: this._revision,
});
} catch (hookErr) {
console.error('[ScryingPool] StateStore.setMatrix: hook emission failed', hookErr);
}
return persistencePromise;
}
}
+115 -14
View File
@@ -16,13 +16,12 @@
/**
* 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.
* Exposes typed surfaces for: settings, socket, users, scenes, notifications, hooks, webrtc.
* All surfaces are built in the constructor — construction is side-effect-free.
*
* 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.
* 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. */
@@ -36,19 +35,116 @@ export class FoundryAdapter {
static SETTING_WEBRTC_MODE = 'webrtcMode';
/**
* Creates a FoundryAdapter. Side-effect-free — no game.* access in constructor.
* 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() {
constructor(game) {
this._game = game ?? {};
/**
* 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.
*
* 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 = {
/** @param {string} key @param {object} config */
register: (key, config) => g.settings?.register(ns, key, config),
/** @param {string} key @returns {unknown} */
get: (key) => g.settings?.get(ns, key) ?? null,
/** @param {string} key @param {unknown} value @returns {Promise<unknown>} */
set: (key, value) => /** @type {Promise<unknown>} */ (g.settings?.set(ns, key, value) ?? Promise.resolve()),
};
/** Socket surface — wraps game.socket. */
this.socket = {
/** @param {string} event @param {object} payload @returns {void|undefined} */
emit: (event, payload) => {
if (g.socket) return g.socket.emit(event, payload);
},
/** @param {string} event @param {Function} handler @returns {void|undefined} */
on: (event, handler) => {
if (g.socket) return g.socket.on(event, handler);
},
/** @param {string} event @param {Function} 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 = {
/** @param {string} userId @returns {object|null} */
get: (userId) => g.users?.get(userId) ?? null,
/** @returns {object[]} */
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,
};
/** Scenes surface — wraps game.scenes. */
this.scenes = {
/** @returns {object|null} The currently active scene. */
current: () => g.scenes?.active ?? null,
/** @param {string} id @returns {object|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 = {
/** @param {string} msg */
info: (msg) => { try { ui.notifications?.info(msg); } catch (err) { console.warn('[ScryingPool] notifications.info:', err); } },
/** @param {string} msg */
warn: (msg) => { try { ui.notifications?.warn(msg); } catch (err) { console.warn('[ScryingPool] notifications.warn:', err); } },
/** @param {string} msg */
error: (msg) => { try { ui.notifications?.error(msg); } catch (err) { console.warn('[ScryingPool] notifications.error:', err); } },
};
/** Hooks surface — wraps FoundryVTT Hooks global. */
this.hooks = {
/** @param {string} event @param {(...args: unknown[]) => void} handler @returns {void|undefined} */
on: (event, handler) => {
if (typeof Hooks !== 'undefined') return Hooks.on(event, handler);
},
/** @param {string} event @param {(...args: unknown[]) => void} handler @returns {void|undefined} */
once: (event, handler) => {
if (typeof Hooks !== 'undefined') return Hooks.once(event, handler);
},
/** @param {string} event @param {(...args: unknown[]) => void} handler @returns {void|undefined} */
off: (event, handler) => {
if (typeof Hooks !== 'undefined') return Hooks.off(event, handler);
},
/** @param {string} event @param {...unknown[]} args @returns {boolean|undefined} */
callAll: (event, ...args) => {
if (typeof Hooks !== 'undefined') return Hooks.callAll(event, ...args);
return false;
},
};
}
/**
@@ -56,6 +152,7 @@ export class FoundryAdapter {
*
* 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: tracks are technically reachable but track.enabled = false does NOT reduce
* inbound WebRTC bandwidth (RTP packets keep arriving). The `'track-disable'` outcome
@@ -68,7 +165,7 @@ export class FoundryAdapter {
* @returns {'track-disable'|'css-fallback'|'unsupported'}
*/
static probeCapability(gameWebrtc) {
if (!gameWebrtc) return 'unsupported';
if (!gameWebrtc || typeof gameWebrtc !== 'object') 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.
@@ -86,8 +183,12 @@ export class FoundryAdapter {
*
* @param {{ client: { getMediaStreamForUser(userId: string): (MediaStream|null|undefined) } }} gameWebrtc
* @returns {{ disableTrack(userId: string): void, enableTrack(userId: string): void }}
* @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');
}
return {
disableTrack(userId) {
try {
+8
View File
@@ -10,3 +10,11 @@ declare const Hooks: {
call(event: string, ...args: unknown[]): boolean;
callAll(event: string, ...args: unknown[]): boolean;
};
declare const ui: {
notifications?: {
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
};
};
+11 -3
View File
@@ -1,7 +1,15 @@
/**
* Generates a unique operation ID for PendingOp tracking.
* @returns {string} A unique identifier string.
* @module utils/uuid
* Unique operation ID generation for PendingOp tracking.
*/
/**
* Generates a unique operation ID.
* Uses crypto.randomUUID() when available; falls back to a time-random composite.
* @returns {string}
*/
export function generateOpId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `op-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
}