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
+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 {