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:
@@ -13,8 +13,6 @@
|
||||
* @module contracts/pending-op
|
||||
*/
|
||||
|
||||
/** @typedef {Object} PendingOp */
|
||||
|
||||
/** Shape version constant for PendingOp. @type {1} */
|
||||
export const PENDING_OP_VERSION = 1;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Vendored
+8
@@ -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
@@ -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)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user