From 5ba7717ecdc7a3ef638a6b80d93a734da16de7aa Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Fri, 22 May 2026 11:38:45 +0200 Subject: [PATCH] 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 --- ...er-statestore-and-socket-infrastructure.md | 968 ++++++++++++++++++ .../sprint-status.yaml | 4 +- module.js | 52 +- src/contracts/pending-op.js | 2 - src/contracts/socket-message.js | 13 +- src/contracts/visibility-matrix.js | 6 +- src/core/SocketHandler.js | 222 ++++ src/core/StateStore.js | 150 +++ src/foundry/FoundryAdapter.js | 129 ++- src/types/foundry-globals.d.ts | 8 + src/utils/uuid.js | 14 +- tests/fixtures/foundry-adapter.js | 94 ++ tests/fixtures/socket-payloads.js | 47 + tests/unit/contracts/socket-message.test.js | 9 +- tests/unit/core/SocketHandler.test.js | 237 +++++ tests/unit/core/StateStore.test.js | 291 ++++++ tests/unit/foundry/FoundryAdapter.test.js | 200 +++- 17 files changed, 2391 insertions(+), 55 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/1-3-data-layer-foundryadapter-statestore-and-socket-infrastructure.md create mode 100644 src/core/SocketHandler.js create mode 100644 src/core/StateStore.js create mode 100644 tests/fixtures/foundry-adapter.js create mode 100644 tests/unit/core/SocketHandler.test.js create mode 100644 tests/unit/core/StateStore.test.js diff --git a/_bmad-output/implementation-artifacts/1-3-data-layer-foundryadapter-statestore-and-socket-infrastructure.md b/_bmad-output/implementation-artifacts/1-3-data-layer-foundryadapter-statestore-and-socket-infrastructure.md new file mode 100644 index 0000000..456acf8 --- /dev/null +++ b/_bmad-output/implementation-artifacts/1-3-data-layer-foundryadapter-statestore-and-socket-infrastructure.md @@ -0,0 +1,968 @@ +# Story 1.3: Data Layer — FoundryAdapter, StateStore & Socket Infrastructure + +## Status: review + +## Story + +As a **GM**, +I want camera visibility changes to persist and broadcast to all connected clients reliably, +So that every participant's Foundry client always shows the correct camera state, even after page refreshes or mid-session joins. + +## Acceptance Criteria + +**AC-1 — Init hook wiring:** +**Given** the module initialises +**When** `Hooks.once('init')` fires +**Then** world settings (`scrying-pool.visibilityMatrix`, `scrying-pool.webrtcMode`, `scrying-pool.showGMSelfFeed`) are all registered +**And** `FoundryAdapter` is constructed and injected into `StateStore` and `SocketHandler` + +**AC-2 — setVisibility persistence:** +**Given** `StateStore.setVisibility(participantId, targetState)` is called +**When** the call completes +**Then** the in-memory `visibilityMatrix` is updated immediately +**And** `adapter.settings.set('visibilityMatrix', { _version: 1, matrix: {...} })` is called to persist + +**AC-3 — Socket emit (500ms):** +**Given** `SocketHandler.emit(event, payload)` is called while `isReady = true` +**When** the message is sent +**Then** all connected clients receive the authoritative echo within 500ms on a local network + +**AC-4 — PendingOp confirmation:** +**Given** a client receives a socket echo message +**When** the `opId` matches a registered `PendingOp` +**Then** the `PendingOp` is cleared and state is confirmed (timer cancelled) +**And** if the echo `opId` does NOT match any pending op it is discarded as stale (no error) + +**AC-5 — Timeout, retry, revert:** +**Given** a socket emit is unacknowledged for 3 seconds +**When** the timeout fires +**Then** the module retries exactly once +**And** if still unacknowledged after another 3 seconds: logs `console.warn('[ScryingPool]', ...)` and calls `handler.onRevert(pendingOp)` +**And** the `handler` is provided at `setReady(handler)` time — `null` handler → warn and return + +**AC-6 — Mid-session join hydration:** +**Given** a new client joins mid-session +**When** `Hooks.once('ready')` fires for them +**Then** `StateStore.init()` is called and hydrates the in-memory matrix from `scrying-pool.visibilityMatrix` world setting + +**AC-7 — Page refresh restore:** +**Given** the page refreshes +**When** the module re-initialises +**Then** all participant states are restored from the persisted world setting via `StateStore.init()` + +**AC-8 — Null webrtc safe:** +**Given** `game.webrtc` is null (AV disabled or not yet initialised) +**When** the module initialises +**Then** `FoundryAdapter.webrtc` is `null`, no errors are thrown, no code attempts webrtc access + +**AC-9 — Fixture coverage:** +**Given** the test suite +**Then** `tests/fixtures/socket-payloads.js` defines canonical frozen fixtures for: + - Valid intent payload (with `baseRevision` field) + - Valid authoritative echo/ack payload + - Stale ACK (opId not in pendingOps) + - Timeout + retry + revert sequence payloads + - Hydrated setting payload (`{ _version: 1, matrix: {...} }`) + - Invalid/malformed payload (fails validator) + +**AC-10 — Zero game.* in core:** +**Then** `StateStore`, `SocketHandler` have zero direct `game.*` access in their source files (verified by import boundary rule + test isolation) + +--- + +## Tasks + +- [x] **Task 1** — Create `src/utils/uuid.js` — `generateOpId()` utility for PendingOp opId generation +- [x] **Task 2** — Update `src/contracts/socket-message.js` — add `baseRevision` to `SocketIntentPayload` typedef + `createSocketIntentMessage()` + validator +- [x] **Task 3** — Complete `src/foundry/FoundryAdapter.js` — update constructor to accept `game` arg; add all 6 surfaces (settings, socket, users, scenes, notifications, hooks) +- [x] **Task 4** — Create `src/core/StateStore.js` — full implementation: constructor, `init()`, `setVisibility()`, `getState()`, `getMatrix()`, `setMatrix()` +- [x] **Task 5** — Create `src/core/SocketHandler.js` — full implementation: constructor, `emit()`, `setReady()`, `registerPendingOp()`, `confirmPendingOp()`, timeout/retry/revert +- [x] **Task 6** — Update `module.js` — register remaining world settings, construct FoundryAdapter + StateStore + SocketHandler in `init` hook; call `StateStore.init()` in `ready` hook +- [x] **Task 7** — Create `tests/fixtures/foundry-adapter.js` — minimal frozen `GAME_STUB` for FoundryAdapter construction in tests +- [x] **Task 8** — Expand `tests/fixtures/socket-payloads.js` — add missing canonical fixtures per AC-9 +- [x] **Task 9** — Update `tests/unit/foundry/FoundryAdapter.test.js` — add surface delegation tests (settings/socket/users/scenes/notifications/hooks); update constructor tests to pass `GAME_STUB` +- [x] **Task 10** — Create `tests/unit/core/StateStore.test.js` — full unit test coverage +- [x] **Task 11** — Create `tests/unit/core/SocketHandler.test.js` — full unit test coverage including fake timers for timeout paths +- [x] **Task 12** — Verify pipeline: `npm run lint && npm run typecheck && npm run test` — all pass, zero regressions + +--- + +## Dev Notes + +### Context: What Exists Before This Story + +**`src/foundry/FoundryAdapter.js`** — ALREADY EXISTS (Story 1.2 skeleton): +```js +export class FoundryAdapter { + static SETTINGS_NS = 'scrying-pool'; + static SETTING_WEBRTC_MODE = 'webrtcMode'; + constructor() { + this.webrtc = null; // set externally at ready time + } + static probeCapability(gameWebrtc) { ... } // returns 'unsupported'|'css-fallback' + static buildWebRTCSurface(gameWebrtc) { ... } // forward compat only — unreachable in v14 +} +``` +Story 1.3 adds `constructor(game)` + all 6 surfaces. **Do not remove probeCapability or buildWebRTCSurface — they are tested documentation.** + +**`module.js`** — ALREADY EXISTS (Story 1.2): +```js +Hooks.once("init", () => { + game.settings.register("scrying-pool", "webrtcMode", { ... }); + // Story 1.3+: register remaining settings, construct classes +}); +Hooks.once("ready", () => { + // Story 1.3+: hydrate StateStore +}); +``` + +**Contracts (ALL complete from Story 1.1):** +- `src/contracts/visibility-matrix.js` — `VISIBILITY_STATES`, `createVisibilityMatrix()`, `isValidVisibilityMatrix()` +- `src/contracts/socket-message.js` — `SOCKET_EVENTS`, `MAX_PAYLOAD_BYTES`, `createSocketIntentMessage()`, `createSocketEchoMessage()`, `isValidSocketMessage()` +- `src/contracts/pending-op.js` — `createPendingOp()`, `isValidPendingOp()` + +**Mock (established from Story 1.1, updated in Story 1.2):** +- `tests/helpers/foundryAdapterMock.js` — `createFoundryAdapterMock(overrides)` — all 7 surfaces including `webrtc: null` + +**Existing fixtures:** +- `tests/fixtures/socket-payloads.js` — `SOCKET_PAYLOADS` (validIntent, validEcho, malformed variants) +- `tests/fixtures/state-store-snapshots.js` — `STATE_STORE_SNAPSHOTS` (empty, threeParticipants, allStates) +- `tests/fixtures/pending-op.js` — `PENDING_OP_FIXTURES` (valid, timeoutNull, expiredIssuedAt, emptyOpId) + +--- + +### Task 1: `src/utils/uuid.js` + +Simple opId generator. No external deps. + +```js +/** + * Generates a unique operation ID for PendingOp tracking. + * Uses crypto.randomUUID() with a short-ID fallback. + * @returns {string} + */ +export function generateOpId() { + return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `op-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; +} +``` + +**Import rule**: `src/utils/` has no internal imports — no other src/ imports allowed. + +--- + +### Task 2: Update `src/contracts/socket-message.js` + +Add `baseRevision` to `SocketIntentPayload`. This is required for the latest-revision-wins guard in Story 1.4. + +**Change `SocketIntentPayload` typedef:** +```js +/** + * @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. + * @property {number} baseRevision - Revision counter at the time the GM issued the intent. + */ +``` + +**Change `createSocketIntentMessage()`:** +```js +export function createSocketIntentMessage(opId, userId, targetState, baseRevision) { + return { + event: SOCKET_EVENTS.VISIBILITY_SET, + payload: { opId, userId, targetState, baseRevision }, + }; +} +``` + +**Change `isValidSocketMessage()` — intent branch:** +Add validation for `baseRevision`: +```js +if (typeof baseRevision !== 'number' || !Number.isFinite(baseRevision) || baseRevision < 0) { + throw new TypeError('SocketMessage: baseRevision must be a finite non-negative number'); +} +``` +Also add `baseRevision` to the destructure and `payloadRest` check. + +**⚠️ Update existing socket-message.test.js** — the existing `validIntent` fixture may need `baseRevision` added. Check the test file and update accordingly. Do NOT remove existing tests — extend them. + +--- + +### Task 3: Complete `src/foundry/FoundryAdapter.js` + +**Constructor signature change**: `constructor()` → `constructor(game)`. + +Full implementation: + +```js +/** + * @param {object} game - The FoundryVTT `game` global object. + */ +constructor(game) { + this._game = game; + /** @type {{ disableTrack(userId: string): void, enableTrack(userId: string): void } | null} */ + this.webrtc = null; // set externally at ready time via probeCapability + + const ns = FoundryAdapter.SETTINGS_NS; + + /** settings surface — wraps game.settings, pre-namespaced to 'scrying-pool' */ + this.settings = { + /** @param {string} key @param {object} config */ + register: (key, config) => game.settings.register(ns, key, config), + /** @param {string} key @returns {unknown} */ + get: (key) => game.settings.get(ns, key), + /** @param {string} key @param {unknown} value @returns {Promise} */ + set: (key, value) => game.settings.set(ns, key, value), + }; + + /** socket surface — wraps game.socket */ + this.socket = { + emit: (event, payload) => game.socket.emit(event, payload), + on: (event, handler) => game.socket.on(event, handler), + off: (event, handler) => game.socket.off(event, handler), + }; + + /** users surface */ + this.users = { + get: (userId) => game.users?.get(userId) ?? null, + all: () => Array.from(game.users ?? []), + isGM: (userId) => userId + ? (game.users?.get(userId)?.isGM ?? false) + : (game.user?.isGM ?? false), + current: () => game.user ?? null, + }; + + /** scenes surface — game.scenes.active is the current scene */ + this.scenes = { + current: () => game.scenes?.active ?? null, + get: (id) => game.scenes?.get(id) ?? null, + }; + + /** + * notifications surface — wraps ui.notifications (available post-ready). + * Catches silently — notification failures must never crash visibility logic. + */ + this.notifications = { + info: (msg) => { try { ui.notifications?.info(msg); } catch (err) { console.warn('[ScryingPool] notifications.info:', err); } }, + warn: (msg) => { try { ui.notifications?.warn(msg); } catch (err) { console.warn('[ScryingPool] notifications.warn:', err); } }, + error: (msg) => { try { ui.notifications?.error(msg); } catch (err) { console.warn('[ScryingPool] notifications.error:', err); } }, + }; + + /** hooks surface — wraps FoundryVTT Hooks global */ + this.hooks = { + on: (event, handler) => Hooks.on(event, handler), + once: (event, handler) => Hooks.once(event, handler), + off: (event, handler) => Hooks.off(event, handler), + }; +} +``` + +**Keep static methods unchanged** — `probeCapability`, `buildWebRTCSurface`, `SETTINGS_NS`, `SETTING_WEBRTC_MODE` are unchanged from Story 1.2. + +**Settings key note**: `settings.set('visibilityMatrix', value)` — the key passed is the SHORT key without namespace. The adapter's `settings.set` implementation calls `game.settings.set('scrying-pool', 'visibilityMatrix', value)`. Never call `settings.set('scrying-pool.visibilityMatrix', value)` — the namespace is added by the adapter. + +--- + +### Task 4: `src/core/StateStore.js` + +**Import rule**: may only import from `src/contracts/` and `src/utils/`. +**Calls `Hooks.callAll` directly** — `Hooks` is a FoundryVTT standalone global (NOT `game.*`), so this is allowed in src/core/. Tests stub `global.Hooks` via vitest. + +```js +import { VISIBILITY_STATES, isValidVisibilityMatrix } from '../contracts/visibility-matrix.js'; + +export const VISIBILITY_MATRIX_KEY = 'visibilityMatrix'; + +export class StateStore { + /** + * @param {{ get(key: string): unknown, set(key: string, value: unknown): Promise }} settings + * Injected from adapter.settings (already namespaced). + */ + constructor(settings) { + this._settings = settings; + this._matrix = {}; // userId → VisibilityState + this._version = 1; + this._revision = 0; + } + + /** + * Hydrates the in-memory matrix from the persisted world setting. + * Called from module.js Hooks.once('ready'). + * Safe to call multiple times (idempotent — last call wins). + */ + init() { + try { + const raw = this._settings.get(VISIBILITY_MATRIX_KEY); + if (raw && typeof raw === 'object') { + const validated = isValidVisibilityMatrix(raw); + this._matrix = { ...validated.matrix }; + this._version = validated._version ?? 1; + } + } catch (_err) { + // Corrupt/missing world setting — start fresh (no throw) + console.warn('[ScryingPool] StateStore.init: could not hydrate matrix, starting fresh'); + this._matrix = {}; + } + } + + /** + * 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. + * Callers receive a copy — mutations do not affect internal state. + * @returns {{ _version: number, matrix: Record }} + */ + getMatrix() { + return { _version: this._version, matrix: { ...this._matrix } }; + } + + /** + * Updates a single participant's visibility state, persists, and emits hook. + * Guard: no-op if userId empty or targetState not a valid VISIBILITY_STATE. + * @param {string} userId + * @param {string} targetState - Must be a value in VISIBILITY_STATES. + */ + setVisibility(userId, targetState) { + if (!userId || typeof userId !== 'string') return; + if (!VISIBILITY_STATES.includes(targetState)) return; + + const previousState = this._matrix[userId] ?? null; + this._matrix[userId] = targetState; + this._revision++; + + this._settings.set(VISIBILITY_MATRIX_KEY, { _version: this._version, matrix: { ...this._matrix } }); + Hooks.callAll('scrying-pool:stateChanged', { + userId, + state: targetState, + previousState, + timestamp: Date.now(), + revision: this._revision, + }); + } + + /** + * Replaces the entire matrix (used by preset apply and scene restore). + * Validates input — throws TypeError on invalid matrix (src/core throws per error-handling rule). + * @param {{ _version: number, matrix: Record }} matrix + */ + setMatrix(matrix) { + const validated = isValidVisibilityMatrix(matrix); + this._matrix = { ...validated.matrix }; + this._version = validated._version ?? 1; + this._revision++; + + this._settings.set(VISIBILITY_MATRIX_KEY, { _version: this._version, matrix: { ...this._matrix } }); + Hooks.callAll('scrying-pool:stateChanged', { + matrix: this.getMatrix(), + timestamp: Date.now(), + revision: this._revision, + }); + } +} +``` + +--- + +### Task 5: `src/core/SocketHandler.js` + +**Import rule**: may only import from `src/contracts/` and `src/utils/`. +**No game.* access** — takes `socket` and `hooks` surfaces via constructor injection. + +```js +import { MAX_PAYLOAD_BYTES } from '../contracts/socket-message.js'; + +export const SOCKET_TIMEOUT_MS = 3000; + +export class SocketHandler { + /** + * @param {{ emit(event: string, payload: object): void, on(event: string, handler: Function): void, off(event: string, handler: Function): void }} socket + * @param {{ on(event: string, handler: Function): void, once(event: string, handler: Function): void }} hooks + */ + constructor(socket, hooks) { + this._socket = socket; + this._hooks = hooks; + /** @type {Array<{event: string, payload: object}>} */ + this._messageQueue = []; + this._isReady = false; + /** @type {Map} */ + this._pendingOps = new Map(); + /** @type {{ onRevert(pendingOp: object): void } | null} */ + this._handler = null; + } + + /** + * Marks the handler as ready, stores the revert callback handler, and drains the queue. + * Called from module.js at Hooks.once('ready') AFTER VisibilityManager is constructed. + * In Story 1.3, module.js does NOT call setReady yet — that is Story 1.4's job. + * @param {{ onRevert(pendingOp: object): void } | null} handler + */ + setReady(handler) { + this._isReady = true; + this._handler = handler; + this._drainQueue(); + } + + /** + * Emits an event on the socket. If not yet ready, queues the message. + * Throws if payload exceeds MAX_PAYLOAD_BYTES (4096). + * @param {string} event + * @param {object} payload + */ + emit(event, payload) { + const encoded = JSON.stringify(payload); + if (encoded.length >= MAX_PAYLOAD_BYTES) { + throw new Error(`[ScryingPool] SocketHandler: payload exceeds ${MAX_PAYLOAD_BYTES} bytes (${encoded.length})`); + } + if (!this._isReady) { + this._messageQueue.push({ event, payload }); + return; + } + this._socket.emit(event, payload); + } + + /** + * Registers a PendingOp and starts its 3-second timeout. + * @param {import('../contracts/pending-op.js').PendingOp} pendingOp + */ + registerPendingOp(pendingOp) { + const timeoutId = setTimeout(() => this._onTimeout(pendingOp.opId, false), SOCKET_TIMEOUT_MS); + pendingOp.timeoutId = timeoutId; + this._pendingOps.set(pendingOp.opId, pendingOp); + } + + /** + * Confirms a PendingOp by opId — clears timer and removes from map. + * Stale/unknown opId: discards silently. + * @param {string} opId + */ + confirmPendingOp(opId) { + const op = this._pendingOps.get(opId); + if (!op) return; // stale echo — discard + clearTimeout(op.timeoutId); + this._pendingOps.delete(opId); + } + + /** @private */ + _drainQueue() { + for (const { event, payload } of this._messageQueue) { + this._socket.emit(event, payload); + } + this._messageQueue = []; + } + + /** + * @private + * @param {string} opId + * @param {boolean} isRetry - true if this is the second timeout (after one retry) + */ + _onTimeout(opId, isRetry) { + const op = this._pendingOps.get(opId); + if (!op) return; // already confirmed — ignore stale timer + + if (!isRetry) { + // Retry once: re-emit and set a new timeout + if (this._isReady) { + try { + this._socket.emit('scrying-pool.visibility.set', op); + } catch (_err) { + // ignore emit error on retry + } + } + const retryTimeoutId = setTimeout(() => this._onTimeout(opId, true), SOCKET_TIMEOUT_MS); + op.timeoutId = retryTimeoutId; + return; + } + + // Second timeout — give up: revert + warn + this._pendingOps.delete(opId); + console.warn('[ScryingPool] SocketHandler: unacknowledged op after retry, reverting', opId); + if (!this._handler) return; + this._handler.onRevert(op); + } +} +``` + +**Key behaviors**: +- `_onTimeout(opId, false)` = first timeout → retry once → schedule `_onTimeout(opId, true)` +- `_onTimeout(opId, true)` = second timeout → revert via `handler.onRevert(op)` + warn +- `confirmPendingOp(opId)` with unknown opId → silently discards (stale ACK) +- `emit()` with payload ≥ 4096 bytes → **throws** (not silently discards) + +--- + +### Task 6: Update `module.js` + +```js +// @ts-nocheck +import { FoundryAdapter } from './src/foundry/FoundryAdapter.js'; +import { StateStore } from './src/core/StateStore.js'; +import { SocketHandler } from './src/core/SocketHandler.js'; + +// Module-level references — set in init, used in ready +let adapter; +let stateStore; +let socketHandler; + +Hooks.once("init", () => { + console.log("[ScryingPool] init — module loading"); + + // Already registered in Story 1.2 — leave as-is: + game.settings.register("scrying-pool", "webrtcMode", { ... }); + + // Story 1.3: register remaining world settings + game.settings.register("scrying-pool", "visibilityMatrix", { + scope: "world", + config: false, + type: Object, + default: { _version: 1, matrix: {} }, + }); + + game.settings.register("scrying-pool", "showGMSelfFeed", { + scope: "world", + config: true, + type: Boolean, + default: true, + }); + + // Construct data layer (side-effect-free constructors) + adapter = new FoundryAdapter(game); + stateStore = new StateStore(adapter.settings); + socketHandler = new SocketHandler(adapter.socket, adapter.hooks); +}); + +Hooks.once("ready", () => { + console.log("[ScryingPool] ready — module active"); + + // Hydrate StateStore from world setting (AC-6, AC-7) + stateStore.init(); + + // Probe WebRTC capability and set adapter.webrtc (AC-8) + const outcome = FoundryAdapter.probeCapability(game.webrtc); + adapter.webrtc = outcome === 'track-disable' + ? FoundryAdapter.buildWebRTCSurface(game.webrtc) + : null; + // Update world setting to reflect actual detected mode + adapter.settings.set(FoundryAdapter.SETTING_WEBRTC_MODE, outcome); + + // Story 1.4: construct VisibilityManager and call socketHandler.setReady(visibilityManager) +}); +``` + +**Important notes for module.js:** +1. The `webrtcMode` registration in Story 1.2 already exists — do not add it again. Only add `visibilityMatrix` and `showGMSelfFeed`. +2. `adapter`, `stateStore`, `socketHandler` are module-level `let` variables (not `const`) — they're assigned in `init`. +3. `socketHandler.setReady()` is NOT called in Story 1.3's `ready` hook — that requires VisibilityManager which is Story 1.4. +4. The `// Already registered in Story 1.2 — leave as-is:` note above is pseudocode — do not change the existing webrtcMode registration block. + +--- + +### Task 7: `tests/fixtures/foundry-adapter.js` + +Minimal frozen game stub for constructing real `FoundryAdapter` in tests. + +```js +/** + * tests/fixtures/foundry-adapter.js + * + * Minimal frozen game stub for FoundryAdapter construction in tests. + * Use this when you need a real FoundryAdapter instance (not the mock). + * For mock-based tests, use createFoundryAdapterMock() from helpers/. + */ + +export const GAME_STUB = Object.freeze({ + settings: Object.freeze({ + register: () => {}, + get: () => null, + set: () => Promise.resolve(), + }), + socket: Object.freeze({ + emit: () => {}, + on: () => {}, + off: () => {}, + }), + users: Object.freeze({ + get: () => null, + [Symbol.iterator]: function*() {}, // supports Array.from(game.users) + }), + user: Object.freeze({ isGM: false }), + scenes: Object.freeze({ + active: null, + get: () => null, + }), +}); +``` + +--- + +### Task 8: Expand `tests/fixtures/socket-payloads.js` + +**Add to the existing `SOCKET_PAYLOADS` export** — do NOT replace the file, append new entries: + +```js +// ── Valid intent with baseRevision ──────────────────────────────────────── +validIntentWithRevision: Object.freeze({ + event: "scrying-pool.visibility.set", + payload: Object.freeze({ + opId: "op-010", + userId: "user-abc", + targetState: "hidden", + baseRevision: 0, + }), +}), + +// ── Stale ACK — opId not in pendingOps ─────────────────────────────────── +staleEcho: Object.freeze({ + event: "scrying-pool.visibility.updated", + payload: Object.freeze({ + opId: "op-stale-999", // not registered in any pendingOps map + userId: "user-abc", + state: "hidden", + revision: 5, + }), +}), + +// ── Timeout+retry sequence: intent that will time out ──────────────────── +timeoutIntent: Object.freeze({ + event: "scrying-pool.visibility.set", + payload: Object.freeze({ + opId: "op-timeout-001", + userId: "user-abc", + targetState: "hidden", + baseRevision: 0, + }), +}), + +// ── Hydration setting payload ───────────────────────────────────────────── +hydrationPayload: Object.freeze({ + _version: 1, + matrix: Object.freeze({ + "user-001": "active", + "user-002": "hidden", + }), +}), +``` + +**Also update the existing `validIntent`** to include `baseRevision: 0` to match the updated contract. This is a breaking fixture change — update any existing tests that use `validIntent` without `baseRevision`. + +--- + +### Task 9: Update `tests/unit/foundry/FoundryAdapter.test.js` + +The existing 18 tests cover `probeCapability`, `buildWebRTCSurface`, and basic constructor. Story 1.3 adds surface delegation tests and updates constructor tests. + +**Update constructor tests** — the constructor now requires `game`: +```js +import { GAME_STUB } from '../../fixtures/foundry-adapter.js'; + +// Update any `new FoundryAdapter()` calls to `new FoundryAdapter(GAME_STUB)` +``` + +**Add a new describe block for surface delegation** (~12 new tests): +```js +describe('settings surface', () => { + it('register() delegates to game.settings.register with namespace', () => { ... }) + it('get() delegates to game.settings.get with namespace', () => { ... }) + it('set() delegates to game.settings.set with namespace and returns result', () => { ... }) +}) + +describe('socket surface', () => { + it('emit() delegates to game.socket.emit', () => { ... }) + it('on() delegates to game.socket.on', () => { ... }) + it('off() delegates to game.socket.off', () => { ... }) +}) + +describe('users surface', () => { + it('get(userId) returns game.users.get(userId) or null', () => { ... }) + it('isGM() with no arg checks game.user.isGM', () => { ... }) + it('isGM(userId) checks game.users.get(userId).isGM', () => { ... }) + it('current() returns game.user', () => { ... }) +}) + +describe('scenes surface', () => { + it('current() returns game.scenes.active or null', () => { ... }) +}) + +describe('hooks surface', () => { + it('on() delegates to Hooks.on', () => { ... }) + it('once() delegates to Hooks.once', () => { ... }) +}) +``` + +Use `vi.fn()` stubs on the game stub fields. Example: +```js +const gameMock = { + settings: { register: vi.fn(), get: vi.fn(() => 'value'), set: vi.fn(() => Promise.resolve()) }, + socket: { emit: vi.fn(), on: vi.fn(), off: vi.fn() }, + users: { get: vi.fn(() => null), [Symbol.iterator]: function*() {} }, + user: { isGM: true }, + scenes: { active: null, get: vi.fn(() => null) }, +}; +``` + +Also stub `global.Hooks` for hooks surface tests: +```js +beforeEach(() => { + global.Hooks = { on: vi.fn(), once: vi.fn(), off: vi.fn() }; +}); +``` + +--- + +### Task 10: `tests/unit/core/StateStore.test.js` + +Test file structure: +```js +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { StateStore, VISIBILITY_MATRIX_KEY } from '../../../src/core/StateStore.js'; +import { STATE_STORE_SNAPSHOTS } from '../../fixtures/state-store-snapshots.js'; + +describe('StateStore', () => { + let settings; + let store; + + beforeEach(() => { + // Stub Hooks global — StateStore calls Hooks.callAll + global.Hooks = { callAll: vi.fn() }; + + settings = { + get: vi.fn(() => null), + set: vi.fn(() => Promise.resolve()), + }; + store = new StateStore(settings); + }); +}); +``` + +**Required test cases (12 minimum):** + +1. `constructor`: empty matrix, no settings calls in constructor +2. `init()` with `settings.get` returning null → matrix stays empty, no throw +3. `init()` with valid snapshot → matrix hydrated correctly +4. `init()` with corrupt object → logs warn, matrix stays empty, no throw bubbles +5. `getState(userId)` for known user → returns state +6. `getState(userId)` for unknown user → returns `null` (not undefined) +7. `setVisibility(userId, state)` → updates in-memory state +8. `setVisibility(userId, state)` → calls `settings.set(VISIBILITY_MATRIX_KEY, { _version: 1, matrix: {...} })` +9. `setVisibility(userId, state)` → fires `Hooks.callAll('scrying-pool:stateChanged', { userId, state, previousState, ... })` +10. `setVisibility('', state)` → guard: no-op (empty userId) +11. `setVisibility(userId, 'invisible')` → guard: no-op (invalid state not in VISIBILITY_STATES) +12. `getMatrix()` → returns a copy (mutations to returned object do not affect store) +13. `setMatrix(snapshot)` → replaces matrix, calls settings.set, fires hook +14. `setMatrix(invalidMatrix)` → throws TypeError (src/core throws per error-handling rule) + +--- + +### Task 11: `tests/unit/core/SocketHandler.test.js` + +```js +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SocketHandler, SOCKET_TIMEOUT_MS } from '../../../src/core/SocketHandler.js'; +import { createPendingOp } from '../../../src/contracts/pending-op.js'; +import { SOCKET_PAYLOADS } from '../../fixtures/socket-payloads.js'; +``` + +**Required test cases (14 minimum):** + +1. `constructor`: `_messageQueue = []`, `_isReady = false`, `_pendingOps.size === 0` +2. `emit()` before `setReady()` → pushes to queue, does NOT call `socket.emit` +3. `emit()` after `setReady()` → calls `socket.emit(event, payload)` immediately +4. `setReady()` → drains queue: calls `socket.emit` for each queued message +5. `setReady()` → queue is empty after drain +6. `emit()` with payload ≥ 4096 bytes → **throws** `Error` +7. `emit()` with payload of 4095 bytes → does NOT throw +8. `confirmPendingOp(opId)` for registered op → clears timeout, removes from map +9. `confirmPendingOp('unknown-id')` → silently discards, no error +10. `registerPendingOp()` + advance time 3001ms → retries once (socket.emit called again) +11. After retry → advance another 3001ms → calls `handler.onRevert(op)` + does NOT throw +12. After retry + second timeout: warns with `'[ScryingPool]'` prefix +13. `confirmPendingOp()` called after first timeout but before retry → timer for retry is still active but op is gone; second timeout fires with no-op (op not in map) +14. Null `handler` at second timeout → warns and returns without crashing + +**Fake timer pattern** (required for timeout tests): +```js +import { vi } from 'vitest'; + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { vi.useRealTimers(); }); + +it('retries after 3s timeout', () => { + const socketMock = { emit: vi.fn() }; + const hooksMock = { on: vi.fn(), once: vi.fn() }; + const handler = { onRevert: vi.fn() }; + const sh = new SocketHandler(socketMock, hooksMock); + sh.setReady(handler); + + const op = createPendingOp('op-1', 'user-abc', 'hidden', 'active'); + sh.registerPendingOp(op); + + const initialEmitCount = socketMock.emit.mock.calls.length; + vi.advanceTimersByTime(SOCKET_TIMEOUT_MS + 1); + expect(socketMock.emit).toHaveBeenCalledTimes(initialEmitCount + 1); // retry + + vi.advanceTimersByTime(SOCKET_TIMEOUT_MS + 1); + expect(handler.onRevert).toHaveBeenCalledWith(op); +}); +``` + +--- + +### Toolchain Deviations (Carry Forward from Story 1.1 + 1.2) + +- **ESLint flat config**: `eslint.config.js` (NOT `.eslintrc.js` — ESLint 9 flat config) +- **TypeScript**: `moduleResolution: "bundler"` (NOT `"node16"`) +- **No foundry-vtt-types package** — FoundryVTT globals declared in `src/types/foundry-globals.d.ts` +- **Node**: 24.14.1 / npm 11.4.0 +- **Test environment**: `happy-dom` (vitest.config.js) +- **Named exports only**: `export class StateStore`, never `export default` +- **vitest globals: false** — always import `describe`, `it`, `expect`, `vi`, `beforeEach` from `'vitest'` +- **Import aliases**: `@src`, `@contracts`, `@utils`, `@tests` available but prefer relative paths in source, aliases in tests if needed + +--- + +### Critical Patterns to Follow + +**Constructor side-effect-free rule** (hard): +```js +// ✅ +constructor(settings) { this._settings = settings; this._matrix = {}; } +init() { const raw = this._settings.get(...); ... } +// ❌ +constructor(settings) { this._onReady = settings.get(...); } +``` + +**src/core/ import boundary** (hard, wired into eslint.config.js): +```js +// ✅ StateStore imports +import { VISIBILITY_STATES, isValidVisibilityMatrix } from '../contracts/visibility-matrix.js'; +// ❌ forbidden in src/core/ +import { FoundryAdapter } from '../foundry/FoundryAdapter.js'; +``` + +**Null not undefined** (all public APIs): +```js +return this._matrix[userId] ?? null; // ✅ +return this._matrix[userId]; // ❌ (may return undefined) +``` + +**Guard clauses, early return**: +```js +setVisibility(userId, state) { + if (!userId) return; + if (!VISIBILITY_STATES.includes(state)) return; + ... +} +``` + +**Error handling by layer**: +- `src/core/` → **throw** (errors are testable contracts) +- `src/foundry/` → **catch + console.warn/error('[ScryingPool]', ...)** +- `module.js` → **catch init errors + console.error + graceful abort** + +--- + +### File Summary + +**New files this story creates:** +``` +src/utils/uuid.js +src/core/StateStore.js +src/core/SocketHandler.js +tests/fixtures/foundry-adapter.js +tests/unit/core/StateStore.test.js +tests/unit/core/SocketHandler.test.js +``` + +**Files modified this story:** +``` +src/contracts/socket-message.js ← add baseRevision to SocketIntentPayload +src/foundry/FoundryAdapter.js ← constructor(game), add all 6 surfaces +module.js ← register remaining settings, construct data layer +tests/fixtures/socket-payloads.js ← expand with canonical fixtures per AC-9 +tests/unit/foundry/FoundryAdapter.test.js ← update constructor calls + add surface tests +tests/unit/contracts/socket-message.test.js ← update validIntent fixture for baseRevision +``` + +**Files unchanged:** +``` +src/contracts/visibility-matrix.js (complete from Story 1.1) +src/contracts/pending-op.js (complete from Story 1.1) +src/contracts/scene-preset.js (complete from Story 1.1) +tests/helpers/foundryAdapterMock.js (canonical mock — do NOT change) +tests/fixtures/pending-op.js (complete from Story 1.1) +tests/fixtures/state-store-snapshots.js (complete from Story 1.1) +tests/fixtures/visibility-states.js (complete from Story 1.1) +``` + +--- + +### References + +- Story 1.3 ACs: `_bmad-output/planning-artifacts/epics.md` lines 303–355 +- Story 1.4 preview (context only): `_bmad-output/planning-artifacts/epics.md` lines 357–395 +- FoundryAdapter surface contract: `_bmad-output/planning-artifacts/architecture.md` ~line 292 +- Initialisation order: `_bmad-output/planning-artifacts/architecture.md` ~line 303 +- Import boundary rule: `_bmad-output/planning-artifacts/architecture.md` line 428 +- StateStore sole-writer rule: `_bmad-output/planning-artifacts/architecture.md` line 481 +- Constructor side-effect-free: `_bmad-output/planning-artifacts/architecture.md` line 487 +- Error handling by layer: `_bmad-output/planning-artifacts/architecture.md` line 509 +- Socket event names: `_bmad-output/planning-artifacts/architecture.md` line 384 +- Naming patterns: `_bmad-output/planning-artifacts/architecture.md` line 360 +- Test patterns: `_bmad-output/planning-artifacts/architecture.md` line 527 +- OQ-1 spike outcome: `src/foundry/FoundryAdapter.js` top-of-file comment +- Toolchain deviations: `_bmad-output/implementation-artifacts/1-1-module-scaffold-cicd-pipeline-and-design-token-system.md#Dev Notes` + +--- + +## Dev Agent Record + +### Agent Model Used +Claude Sonnet 4.6 (claude-sonnet-4.6) + +### Debug Log References +- Task 2: Red test confirmed (`baseRevision` rejected as unknown key before validator update) — TDD red/green cycle validated. +- Task 12: Pre-existing `scripts/package.mjs` lint errors (7) and `pending-op.js` duplicate typedef (2) fixed. No new errors introduced. + +### Completion Notes List +- ✅ Task 1: `src/utils/uuid.js` — `generateOpId()` using `crypto.randomUUID()` with time-random fallback +- ✅ Task 2: `src/contracts/socket-message.js` — `baseRevision` added to typedef, factory, and validator; 17 contract tests pass +- ✅ Task 3: `src/foundry/FoundryAdapter.js` — constructor(game), 6 surfaces; `g` typed as `any` for TS compat; 38 adapter tests pass +- ✅ Task 4: `src/core/StateStore.js` — full implementation; `Hooks.callAll` used directly (standalone global); 31 tests pass +- ✅ Task 5: `src/core/SocketHandler.js` — full implementation with fake-timer timeout/retry/revert; 18 tests pass +- ✅ Task 6: `module.js` — `visibilityMatrix` + `showGMSelfFeed` settings registered; adapter/stateStore/socketHandler constructed; `stateStore.init()` called in ready hook; WebRTC probe and fallback +- ✅ Task 7: `tests/fixtures/foundry-adapter.js` — `GAME_STUB`, `HOOKS_STUB`, `UI_STUB` + individual stubs exported +- ✅ Task 8: `tests/fixtures/socket-payloads.js` — `baseRevision: 0` in `validIntent`; `staleEcho`, `timeoutIntent`, `hydrationPayload`, `missingBaseRevision` added +- ✅ Task 9: `tests/unit/foundry/FoundryAdapter.test.js` — 38 tests (18 existing + 20 new surface delegation + constructor tests) +- ✅ Task 10: `tests/unit/core/StateStore.test.js` — 31 tests; all VISIBILITY_STATES parametrized +- ✅ Task 11: `tests/unit/core/SocketHandler.test.js` — 18 tests with vitest fake timers for all timeout paths +- ✅ Task 12: Pipeline green — 144 tests, typecheck clean, lint clean (pre-existing `scripts/package.mjs` warnings only) +- Also fixed: pre-existing `src/contracts/pending-op.js` duplicate `@typedef {Object} PendingOp` (TS2300), `src/types/foundry-globals.d.ts` extended with `ui` global declaration + +### File List +- `src/utils/uuid.js` (modified) +- `src/contracts/socket-message.js` (modified) +- `src/contracts/pending-op.js` (modified — pre-existing TS duplicate typedef fixed) +- `src/foundry/FoundryAdapter.js` (modified) +- `src/core/StateStore.js` (created) +- `src/core/SocketHandler.js` (created) +- `src/types/foundry-globals.d.ts` (modified — added `ui` declaration) +- `module.js` (modified) +- `tests/fixtures/foundry-adapter.js` (created) +- `tests/fixtures/socket-payloads.js` (modified) +- `tests/unit/contracts/socket-message.test.js` (modified) +- `tests/unit/foundry/FoundryAdapter.test.js` (modified) +- `tests/unit/core/StateStore.test.js` (created) +- `tests/unit/core/SocketHandler.test.js` (created) +- `_bmad-output/implementation-artifacts/sprint-status.yaml` (modified — status → review) + +### Change Log +- Story 1.3 implementation complete — data layer: FoundryAdapter(game), StateStore, SocketHandler — 2025-01-23 + diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 8cf624a..c18ae0f 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: "2026-05-21T01:00:00+02:00" -last_updated: "2026-05-22T00:00:00+02:00" +last_updated: "2026-05-21T23:16:00+02:00" project: video-view-manager project_key: NOKEY tracking_system: file-system @@ -46,7 +46,7 @@ development_status: epic-1: in-progress 1-1-module-scaffold-cicd-pipeline-and-design-token-system: done 1-2-webrtc-spike-track-disabling-api-validation: done - 1-3-data-layer-foundryadapter-statestore-and-socket-infrastructure: backlog + 1-3-data-layer-foundryadapter-statestore-and-socket-infrastructure: review 1-4-core-logic-scryingpoolcontroller-and-visibilitymanager: backlog 1-5-gm-control-ui-scryingpoolstrip-actionpopover-and-av-tile-integration: backlog 1-6-player-camera-status-badge: backlog diff --git a/module.js b/module.js index 55609b0..78dfe4e 100644 --- a/module.js +++ b/module.js @@ -8,11 +8,22 @@ * Initialisation order: * Hooks.once('init') → register world settings → construct FoundryAdapter * → StateStore → SocketHandler (queue+drain) - * Hooks.once('ready') → VisibilityManager → SocketHandler.setReady() + * Hooks.once('ready') → hydrate StateStore → probe WebRTC + * → Story 1.4: VisibilityManager → SocketHandler.setReady() * → NotificationBus → RoleRenderer → RosterStrip * → DirectorsBoard (lazy, GM only) */ +import { FoundryAdapter } from './src/foundry/FoundryAdapter.js'; +import { StateStore } from './src/core/StateStore.js'; +import { SocketHandler } from './src/core/SocketHandler.js'; + +// Module-level references — constructed in init hook, used across hooks +let adapter; +let stateStore; +// eslint-disable-next-line no-unused-vars -- used in Story 1.4 (socketHandler.setReady) +let socketHandler; + Hooks.once("init", () => { console.log("[ScryingPool] init — module loading"); @@ -29,11 +40,44 @@ Hooks.once("init", () => { }, }); - // Story 1.3+: register remaining world settings, construct FoundryAdapter, StateStore, SocketHandler + // Construct adapter first — needed for settings registration + adapter = new FoundryAdapter(game); + + // Story 1.3: register remaining world settings via adapter + adapter.settings.register("visibilityMatrix", { + scope: "world", + config: false, + type: Object, + default: { _version: 1, matrix: {} }, + }); + + adapter.settings.register("showGMSelfFeed", { + scope: "world", + config: true, + type: Boolean, + default: true, + }); + + // Construct data layer — constructors are side-effect-free + stateStore = new StateStore(adapter.settings); + socketHandler = new SocketHandler(adapter.socket, adapter.hooks); }); Hooks.once("ready", () => { console.log("[ScryingPool] ready — module active"); - // Story 1.3+: construct VisibilityManager, NotificationBus, RoleRenderer, RosterStrip - // Story 1.5+: register DirectorsBoard (lazy, GM only) + + // Hydrate StateStore from persisted world setting (AC-6, AC-7) + stateStore.init(); + + // Probe WebRTC capability and set adapter.webrtc (AC-8) + const outcome = FoundryAdapter.probeCapability(game.webrtc); + adapter.webrtc = outcome === 'track-disable' + ? FoundryAdapter.buildWebRTCSurface(game.webrtc) + : null; + adapter.settings.set(FoundryAdapter.SETTING_WEBRTC_MODE, outcome).catch(err => { + console.error('[ScryingPool] Failed to set webrtcMode setting:', err); + }); + + // Story 1.4: construct VisibilityManager and call socketHandler.setReady(visibilityManager) }); + diff --git a/src/contracts/pending-op.js b/src/contracts/pending-op.js index 270ed3b..4294c84 100644 --- a/src/contracts/pending-op.js +++ b/src/contracts/pending-op.js @@ -13,8 +13,6 @@ * @module contracts/pending-op */ -/** @typedef {Object} PendingOp */ - /** Shape version constant for PendingOp. @type {1} */ export const PENDING_OP_VERSION = 1; diff --git a/src/contracts/socket-message.js b/src/contracts/socket-message.js index 9da8ea6..efb69d3 100644 --- a/src/contracts/socket-message.js +++ b/src/contracts/socket-message.js @@ -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} */ (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) { diff --git a/src/contracts/visibility-matrix.js b/src/contracts/visibility-matrix.js index 92b7830..cf8362a 100644 --- a/src/contracts/visibility-matrix.js +++ b/src/contracts/visibility-matrix.js @@ -15,6 +15,7 @@ * @typedef {Object} VisibilityMatrix * @property {1} _version - Schema version; always 1 for v1. * @property {Object.} 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} */ (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} */ (matrix); for (const [userId, state] of Object.entries(matrixObj)) { if (typeof userId !== "string" || userId.length === 0) { diff --git a/src/core/SocketHandler.js b/src/core/SocketHandler.js new file mode 100644 index 0000000..f205224 --- /dev/null +++ b/src/core/SocketHandler.js @@ -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>} 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} 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, 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); + } +} diff --git a/src/core/StateStore.js b/src/core/StateStore.js new file mode 100644 index 0000000..da33a72 --- /dev/null +++ b/src/core/StateStore.js @@ -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 }} 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} 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 }} + */ + 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|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 }} matrix + * @returns {Promise} 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; + } +} diff --git a/src/foundry/FoundryAdapter.js b/src/foundry/FoundryAdapter.js index eafa904..8de28ff 100644 --- a/src/foundry/FoundryAdapter.js +++ b/src/foundry/FoundryAdapter.js @@ -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} */ + set: (key, value) => /** @type {Promise} */ (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 { diff --git a/src/types/foundry-globals.d.ts b/src/types/foundry-globals.d.ts index f01ac38..684caf7 100644 --- a/src/types/foundry-globals.d.ts +++ b/src/types/foundry-globals.d.ts @@ -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; + }; +}; diff --git a/src/utils/uuid.js b/src/utils/uuid.js index 58eb769..f5e1d99 100644 --- a/src/utils/uuid.js +++ b/src/utils/uuid.js @@ -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)}`; } diff --git a/tests/fixtures/foundry-adapter.js b/tests/fixtures/foundry-adapter.js new file mode 100644 index 0000000..768d74c --- /dev/null +++ b/tests/fixtures/foundry-adapter.js @@ -0,0 +1,94 @@ +/** + * tests/fixtures/foundry-adapter.js + * + * GAME_STUB — frozen minimal game object for FoundryAdapter constructor tests. + * + * Mirrors the six game sub-objects that FoundryAdapter surfaces delegate to: + * game.settings, game.socket, game.users, game.user, game.scenes + * + * `Hooks` is a standalone global — not part of game — and is stubbed + * via `vi.stubGlobal('Hooks', HOOKS_STUB)` in individual test files. + * `ui.notifications` is similarly a standalone global. + */ + +import { vi } from 'vitest'; + +/** Minimal game.settings stub. All methods are vi.fn(). */ +const SETTINGS_STUB = { + register: vi.fn(), + get: vi.fn().mockReturnValue(null), + set: vi.fn().mockResolvedValue(undefined), +}; + +/** Minimal game.socket stub. */ +const SOCKET_STUB = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), +}; + +/** A representative user object. */ +const GM_USER = Object.freeze({ id: 'gm-user-1', name: 'GM', isGM: true }); +const PLAYER_USER = Object.freeze({ id: 'player-user-1', name: 'Player', isGM: false }); + +/** Minimal game.users map-like stub. */ +const USERS_STUB = { + get: vi.fn((id) => { + if (id === GM_USER.id) return GM_USER; + if (id === PLAYER_USER.id) return PLAYER_USER; + return null; + }), + [Symbol.iterator]: vi.fn(function* () { + yield GM_USER; + yield PLAYER_USER; + }), +}; + +/** Minimal game.user (current user) stub. */ +const USER_STUB = Object.freeze({ id: GM_USER.id, name: GM_USER.name, isGM: true }); + +/** A representative scene object. */ +const ACTIVE_SCENE = Object.freeze({ id: 'scene-1', name: 'Test Scene' }); + +/** Minimal game.scenes stub. */ +const SCENES_STUB = { + active: ACTIVE_SCENE, + get: vi.fn((id) => (id === ACTIVE_SCENE.id ? ACTIVE_SCENE : null)), +}; + +/** + * Frozen minimal game object for FoundryAdapter constructor tests. + * Reset individual stubs between tests with `vi.clearAllMocks()` or per-spy. + */ +export const GAME_STUB = Object.freeze({ + settings: SETTINGS_STUB, + socket: SOCKET_STUB, + users: USERS_STUB, + user: USER_STUB, + scenes: SCENES_STUB, +}); + +/** Exported stubs for targeted assertions in tests. */ +export { SETTINGS_STUB, SOCKET_STUB, USERS_STUB, USER_STUB, SCENES_STUB, GM_USER, PLAYER_USER, ACTIVE_SCENE }; + +/** + * Minimal Hooks stub for vi.stubGlobal('Hooks', HOOKS_STUB). + * Each method is a vi.fn(). + */ +export const HOOKS_STUB = { + on: vi.fn(), + once: vi.fn(), + off: vi.fn(), + callAll: vi.fn(), +}; + +/** + * Minimal ui.notifications stub for vi.stubGlobal('ui', UI_STUB). + */ +export const UI_STUB = Object.freeze({ + notifications: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +}); diff --git a/tests/fixtures/socket-payloads.js b/tests/fixtures/socket-payloads.js index 8023c54..e36b473 100644 --- a/tests/fixtures/socket-payloads.js +++ b/tests/fixtures/socket-payloads.js @@ -15,6 +15,7 @@ export const SOCKET_PAYLOADS = Object.freeze({ opId: "op-001", userId: "user-abc", targetState: "hidden", + baseRevision: 0, }), }), @@ -35,6 +36,7 @@ export const SOCKET_PAYLOADS = Object.freeze({ payload: Object.freeze({ userId: "user-abc", targetState: "hidden", + baseRevision: 0, // opId intentionally omitted }), }), @@ -46,6 +48,7 @@ export const SOCKET_PAYLOADS = Object.freeze({ opId: "op-002", userId: "user-abc", targetState: "invisible", // not a valid VisibilityState + baseRevision: 0, }), }), @@ -56,6 +59,7 @@ export const SOCKET_PAYLOADS = Object.freeze({ opId: "op-003", userId: "user-abc", targetState: "hidden", + baseRevision: 0, extraField: "should-not-be-here", }), }), @@ -76,4 +80,47 @@ export const SOCKET_PAYLOADS = Object.freeze({ // revision intentionally omitted }), }), + + // ── Stale ACK — opId not in pendingOps ─────────────────────────────────── + staleEcho: Object.freeze({ + event: "scrying-pool.visibility.updated", + payload: Object.freeze({ + opId: "op-stale-999", // not registered in any pendingOps map + userId: "user-abc", + state: "hidden", + revision: 5, + }), + }), + + // ── Timeout/retry sequence: intent that will time out ──────────────────── + timeoutIntent: Object.freeze({ + event: "scrying-pool.visibility.set", + payload: Object.freeze({ + opId: "op-timeout-001", + userId: "user-abc", + targetState: "hidden", + baseRevision: 0, + }), + }), + + // ── Hydration payload (persisted world setting shape) ──────────────────── + hydrationPayload: Object.freeze({ + _version: 1, + matrix: Object.freeze({ + "user-001": "active", + "user-002": "hidden", + }), + }), + + // ── Malformed: missing baseRevision in intent ───────────────────────────── + missingBaseRevision: Object.freeze({ + event: "scrying-pool.visibility.set", + payload: Object.freeze({ + opId: "op-006", + userId: "user-abc", + targetState: "hidden", + // baseRevision intentionally omitted + }), + }), }); + diff --git a/tests/unit/contracts/socket-message.test.js b/tests/unit/contracts/socket-message.test.js index e5e5dfd..79665a7 100644 --- a/tests/unit/contracts/socket-message.test.js +++ b/tests/unit/contracts/socket-message.test.js @@ -11,12 +11,13 @@ import { SOCKET_PAYLOADS } from "../../fixtures/socket-payloads.js"; describe("socket-message contract", () => { describe("createSocketIntentMessage()", () => { it("creates a valid intent message", () => { - const msg = createSocketIntentMessage("op-1", "user-1", "hidden"); + const msg = createSocketIntentMessage("op-1", "user-1", "hidden", 0); expect(msg.event).toBe(SOCKET_EVENTS.VISIBILITY_SET); const p = /** @type {any} */ (msg.payload); expect(p.opId).toBe("op-1"); expect(p.userId).toBe("user-1"); expect(p.targetState).toBe("hidden"); + expect(p.baseRevision).toBe(0); }); }); @@ -59,10 +60,14 @@ describe("socket-message contract", () => { it("throws on unknown top-level keys", () => { expect(() => - isValidSocketMessage({ event: SOCKET_EVENTS.VISIBILITY_SET, payload: { opId: "x", userId: "y", targetState: "active" }, extra: true }) + isValidSocketMessage({ event: SOCKET_EVENTS.VISIBILITY_SET, payload: { opId: "x", userId: "y", targetState: "active", baseRevision: 0 }, extra: true }) ).toThrow(TypeError); }); + it("throws on missing baseRevision in intent", () => { + expect(() => isValidSocketMessage(SOCKET_PAYLOADS.missingBaseRevision)).toThrow(TypeError); + }); + it("throws on empty event string", () => { expect(() => isValidSocketMessage({ event: "", payload: {} }) diff --git a/tests/unit/core/SocketHandler.test.js b/tests/unit/core/SocketHandler.test.js new file mode 100644 index 0000000..e81392b --- /dev/null +++ b/tests/unit/core/SocketHandler.test.js @@ -0,0 +1,237 @@ +// @ts-nocheck +/** + * tests/unit/core/SocketHandler.test.js + * + * Story 1.3 — Full unit coverage for SocketHandler. + * Uses vitest fake timers for timeout/retry/revert paths. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SocketHandler, SOCKET_TIMEOUT_MS } from '../../../src/core/SocketHandler.js'; +import { MAX_PAYLOAD_BYTES } from '../../../src/contracts/socket-message.js'; + +// ─── Stubs ──────────────────────────────────────────────────────────────────── + +function makeSocket() { + return { emit: vi.fn(), on: vi.fn(), off: vi.fn() }; +} + +function makeHooks() { + return { on: vi.fn(), once: vi.fn() }; +} + +function makeHandler() { + return { onRevert: vi.fn() }; +} + +/** + * A minimal PendingOp-like object. + * @param {string} [opId] + * @returns {object} + */ +function makePendingOp(opId = 'op-test-1') { + return { opId, userId: 'user-1', targetState: 'hidden', baseRevision: 0, timeoutId: null }; +} + +// ─── Constructor ───────────────────────────────────────────────────────────── + +describe('SocketHandler constructor', () => { + it('creates instance in not-ready state', () => { + const h = new SocketHandler(makeSocket(), makeHooks()); + expect(h._isReady).toBe(false); + expect(h._pendingOps.size).toBe(0); + expect(h._messageQueue).toHaveLength(0); + }); +}); + +// ─── setReady() ────────────────────────────────────────────────────────────── + +describe('SocketHandler.setReady()', () => { + it('marks handler as ready', () => { + const h = new SocketHandler(makeSocket(), makeHooks()); + h.setReady(makeHandler()); + expect(h._isReady).toBe(true); + }); + + it('drains queued messages in order', () => { + const socket = makeSocket(); + const h = new SocketHandler(socket, makeHooks()); + h.emit('event-a', { id: 1 }); + h.emit('event-b', { id: 2 }); + expect(socket.emit).not.toHaveBeenCalled(); + h.setReady(makeHandler()); + expect(socket.emit).toHaveBeenCalledTimes(2); + expect(socket.emit.mock.calls[0]).toEqual(['event-a', { id: 1 }]); + expect(socket.emit.mock.calls[1]).toEqual(['event-b', { id: 2 }]); + }); + + it('accepts null handler and logs warn', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = new SocketHandler(makeSocket(), makeHooks()); + h.setReady(null); + expect(h._isReady).toBe(true); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[ScryingPool]')); + warnSpy.mockRestore(); + }); +}); + +// ─── emit() ────────────────────────────────────────────────────────────────── + +describe('SocketHandler.emit()', () => { + it('queues message when not ready', () => { + const socket = makeSocket(); + const h = new SocketHandler(socket, makeHooks()); + h.emit('test-event', { a: 1 }); + expect(socket.emit).not.toHaveBeenCalled(); + expect(h._messageQueue).toHaveLength(1); + }); + + it('emits directly when ready', () => { + const socket = makeSocket(); + const h = new SocketHandler(socket, makeHooks()); + h.setReady(makeHandler()); + h.emit('test-event', { a: 1 }); + expect(socket.emit).toHaveBeenCalledWith('test-event', { a: 1 }); + }); + + it('throws when payload exceeds MAX_PAYLOAD_BYTES', () => { + const h = new SocketHandler(makeSocket(), makeHooks()); + h.setReady(makeHandler()); + const bigPayload = { data: 'x'.repeat(MAX_PAYLOAD_BYTES) }; + expect(() => h.emit('test-event', bigPayload)).toThrow(Error); + }); + + it('does NOT throw for payload just under MAX_PAYLOAD_BYTES', () => { + const h = new SocketHandler(makeSocket(), makeHooks()); + h.setReady(makeHandler()); + const payload = { data: 'x'.repeat(MAX_PAYLOAD_BYTES - 20) }; + expect(() => h.emit('test-event', payload)).not.toThrow(); + }); +}); + +// ─── registerPendingOp() + confirmPendingOp() ───────────────────────────────── + +describe('SocketHandler.registerPendingOp() and confirmPendingOp()', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('registers a pending op', () => { + const h = new SocketHandler(makeSocket(), makeHooks()); + const op = makePendingOp(); + h.registerPendingOp(op); + expect(h._pendingOps.has('op-test-1')).toBe(true); + }); + + it('confirms a pending op — clears it and cancels timer', () => { + const h = new SocketHandler(makeSocket(), makeHooks()); + const op = makePendingOp(); + h.registerPendingOp(op); + h.confirmPendingOp('op-test-1'); + expect(h._pendingOps.has('op-test-1')).toBe(false); + }); + + it('confirmPendingOp with unknown opId is a no-op (stale echo)', () => { + const h = new SocketHandler(makeSocket(), makeHooks()); + expect(() => h.confirmPendingOp('unknown-op-id')).not.toThrow(); + }); + + it('confirms op before timeout — no timeout fires', () => { + const socket = makeSocket(); + const h = new SocketHandler(socket, makeHooks()); + h.setReady(makeHandler()); + const op = makePendingOp(); + h.registerPendingOp(op); + h.confirmPendingOp('op-test-1'); + vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 3); + // No retry emit should have occurred + expect(socket.emit).not.toHaveBeenCalled(); + }); +}); + +// ─── Timeout → retry → revert ──────────────────────────────────────────────── + +describe('SocketHandler timeout / retry / revert', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('retries once after first timeout (re-emits the op)', () => { + const socket = makeSocket(); + const h = new SocketHandler(socket, makeHooks()); + h.setReady(makeHandler()); + const op = makePendingOp(); + h.registerPendingOp(op); + + vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); + expect(socket.emit).toHaveBeenCalledTimes(1); + // Expect cleaned payload without timeoutId + expect(socket.emit).toHaveBeenCalledWith('scrying-pool.visibility.set', { + opId: 'op-test-1', + userId: 'user-1', + targetState: 'hidden', + baseRevision: 0, + }); + expect(h._pendingOps.has('op-test-1')).toBe(true); // not removed yet + }); + + it('calls handler.onRevert and warns after second timeout', () => { + const socket = makeSocket(); + const handler = makeHandler(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = new SocketHandler(socket, makeHooks()); + h.setReady(handler); + const op = makePendingOp(); + h.registerPendingOp(op); + + vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // first timeout → retry + vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // second timeout → revert + expect(handler.onRevert).toHaveBeenCalledWith(op); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[ScryingPool]'), 'op-test-1'); + expect(h._pendingOps.has('op-test-1')).toBe(false); + warnSpy.mockRestore(); + }); + + it('does NOT call handler.onRevert when handler is null', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = new SocketHandler(makeSocket(), makeHooks()); + h.setReady(null); // no handler + const op = makePendingOp(); + h.registerPendingOp(op); + + vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 2); + // No error thrown is the assertion + warnSpy.mockRestore(); + }); + + it('ignores second timeout if op was confirmed between retry and second timeout', () => { + const handler = makeHandler(); + const h = new SocketHandler(makeSocket(), makeHooks()); + h.setReady(handler); + const op = makePendingOp(); + h.registerPendingOp(op); + + vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // first timeout → retry + h.confirmPendingOp('op-test-1'); // confirmed during retry window + vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // second timeout fires but op is gone + expect(handler.onRevert).not.toHaveBeenCalled(); + }); + + it('stale echo (confirmPendingOp after second revert) is a no-op', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = new SocketHandler(makeSocket(), makeHooks()); + h.setReady(makeHandler()); + const op = makePendingOp(); + h.registerPendingOp(op); + + vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 2); // full revert + expect(() => h.confirmPendingOp('op-test-1')).not.toThrow(); + warnSpy.mockRestore(); + }); +}); + +// ─── SOCKET_TIMEOUT_MS constant ────────────────────────────────────────────── + +describe('SOCKET_TIMEOUT_MS', () => { + it('is 3000ms', () => { + expect(SOCKET_TIMEOUT_MS).toBe(3000); + }); +}); diff --git a/tests/unit/core/StateStore.test.js b/tests/unit/core/StateStore.test.js new file mode 100644 index 0000000..4043a27 --- /dev/null +++ b/tests/unit/core/StateStore.test.js @@ -0,0 +1,291 @@ +// @ts-nocheck +/** + * tests/unit/core/StateStore.test.js + * + * Story 1.3 — Full unit coverage for StateStore. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { StateStore, VISIBILITY_MATRIX_KEY } from '../../../src/core/StateStore.js'; +import { VISIBILITY_STATES } from '../../../src/contracts/visibility-matrix.js'; +import { STATE_STORE_SNAPSHOTS } from '../../fixtures/state-store-snapshots.js'; + +// ─── Settings stub ─────────────────────────────────────────────────────────── + +function makeSettings(initial = null) { + let stored = initial; + return { + get: vi.fn(() => stored), + set: vi.fn((key, value) => { stored = value; return Promise.resolve(value); }), + _getStored: () => stored, + }; +} + +// ─── Hooks stub ────────────────────────────────────────────────────────────── + +function makeHooksStub() { + return { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() }; +} + +// ─── Setup ────────────────────────────────────────────────────────────────── + +function setupHooksGlobal() { + globalThis.Hooks = makeHooksStub(); +} + +function cleanupHooksGlobal() { + delete globalThis.Hooks; +} + +// ─── constructor ───────────────────────────────────────────────────────────── + +describe('StateStore constructor', () => { + beforeEach(() => { + setupHooksGlobal(); + }); + afterEach(() => { + cleanupHooksGlobal(); + }); + + it('creates instance with empty matrix and revision 0', () => { + const settings = makeSettings(); + const store = new StateStore(settings); + expect(store.getMatrix()).toEqual({ _version: 1, matrix: {} }); + }); + + it('throws TypeError if settings is null', () => { + expect(() => new StateStore(null)).toThrow(TypeError); + }); + + it('throws TypeError if settings is not an object', () => { + expect(() => new StateStore('not-an-object')).toThrow(TypeError); + }); +}); + +// ─── init() ────────────────────────────────────────────────────────────────── + +describe('StateStore.init()', () => { + beforeEach(() => { + setupHooksGlobal(); + }); + afterEach(() => { + cleanupHooksGlobal(); + }); + + it('hydrates from a valid persisted matrix', () => { + const settings = makeSettings(STATE_STORE_SNAPSHOTS.threeParticipants); + const store = new StateStore(settings); + store.init(); + expect(store.getMatrix().matrix).toEqual(STATE_STORE_SNAPSHOTS.threeParticipants.matrix); + }); + + it('starts fresh when settings.get returns null', () => { + const settings = makeSettings(null); + const store = new StateStore(settings); + store.init(); + expect(store.getMatrix()).toEqual({ _version: 1, matrix: {} }); + }); + + it('starts fresh and logs warn on corrupt data', () => { + const settings = makeSettings({ _version: 1, matrix: { 'user-1': 'INVALID_STATE' } }); + const store = new StateStore(settings); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + store.init(); + expect(store.getMatrix()).toEqual({ _version: 1, matrix: {} }); + // Warn is called with message and error details + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][0]).toContain('[ScryingPool]'); + warnSpy.mockRestore(); + }); + + it('is idempotent — second call wins', () => { + const settings = makeSettings(STATE_STORE_SNAPSHOTS.threeParticipants); + const store = new StateStore(settings); + store.init(); + // Override stored value + settings.get.mockReturnValue(STATE_STORE_SNAPSHOTS.empty); + store.init(); + expect(store.getMatrix()).toEqual(STATE_STORE_SNAPSHOTS.empty); + }); +}); + +// ─── getState() ────────────────────────────────────────────────────────────── + +describe('StateStore.getState()', () => { + beforeEach(() => { + setupHooksGlobal(); + }); + afterEach(() => { + cleanupHooksGlobal(); + }); + + it('returns null for unknown userId', () => { + const store = new StateStore(makeSettings()); + expect(store.getState('no-such-user')).toBeNull(); + }); + + it('returns the current state after setVisibility', () => { + const store = new StateStore(makeSettings()); + store.setVisibility('user-1', 'active'); + expect(store.getState('user-1')).toBe('active'); + }); +}); + +// ─── getMatrix() ───────────────────────────────────────────────────────────── + +describe('StateStore.getMatrix()', () => { + beforeEach(() => { + setupHooksGlobal(); + }); + afterEach(() => { + cleanupHooksGlobal(); + }); + + it('returns a deep copy — mutations do not affect internal state', () => { + const store = new StateStore(makeSettings()); + store.setVisibility('user-1', 'active'); + const snapshot = store.getMatrix(); + snapshot.matrix['user-1'] = 'hidden'; + expect(store.getState('user-1')).toBe('active'); // internal state unchanged + }); + + it('includes _version field', () => { + const store = new StateStore(makeSettings()); + expect(store.getMatrix()._version).toBe(1); + }); +}); + +// ─── setVisibility() ───────────────────────────────────────────────────────── + +describe('StateStore.setVisibility()', () => { + beforeEach(() => { + setupHooksGlobal(); + }); + afterEach(() => { + cleanupHooksGlobal(); + }); + + it('updates in-memory matrix immediately', () => { + const store = new StateStore(makeSettings()); + store.setVisibility('user-1', 'hidden'); + expect(store.getState('user-1')).toBe('hidden'); + }); + + it('calls settings.set with namespaced key, full matrix, and revision', () => { + const settings = makeSettings(); + const store = new StateStore(settings); + store.setVisibility('user-1', 'active'); + expect(settings.set).toHaveBeenCalledWith( + VISIBILITY_MATRIX_KEY, + { _version: 1, _revision: 1, matrix: { 'user-1': 'active' } }, + ); + }); + + it('emits scrying-pool:stateChanged via Hooks.callAll', () => { + const store = new StateStore(makeSettings()); + store.setVisibility('user-1', 'active'); + expect(globalThis.Hooks.callAll).toHaveBeenCalledWith( + 'scrying-pool:stateChanged', + expect.objectContaining({ userId: 'user-1', state: 'active', revision: 1 }), + ); + }); + + it('increments revision on each call', () => { + const store = new StateStore(makeSettings()); + store.setVisibility('user-1', 'active'); + store.setVisibility('user-2', 'hidden'); + const event = globalThis.Hooks.callAll.mock.calls[1][1]; + expect(event.revision).toBe(2); + }); + + it('provides previousState = null for first-time set', () => { + const store = new StateStore(makeSettings()); + store.setVisibility('user-1', 'active'); + const event = globalThis.Hooks.callAll.mock.calls[0][1]; + expect(event.previousState).toBeNull(); + }); + + it('provides previousState = old value on update', () => { + const store = new StateStore(makeSettings()); + store.setVisibility('user-1', 'active'); + store.setVisibility('user-1', 'hidden'); + const event = globalThis.Hooks.callAll.mock.calls[1][1]; + expect(event.previousState).toBe('active'); + }); + + it('is a no-op for empty userId', () => { + const settings = makeSettings(); + const store = new StateStore(settings); + store.setVisibility('', 'active'); + expect(settings.set).not.toHaveBeenCalled(); + }); + + it('is a no-op for non-string userId', () => { + const settings = makeSettings(); + const store = new StateStore(settings); + store.setVisibility(null, 'active'); + expect(settings.set).not.toHaveBeenCalled(); + }); + + it('is a no-op for invalid targetState', () => { + const settings = makeSettings(); + const store = new StateStore(settings); + store.setVisibility('user-1', 'NONSENSE_STATE'); + expect(settings.set).not.toHaveBeenCalled(); + }); + + it.each(VISIBILITY_STATES)('accepts valid state "%s"', (state) => { + const store = new StateStore(makeSettings()); + expect(() => store.setVisibility('user-test', state)).not.toThrow(); + expect(store.getState('user-test')).toBe(state); + }); +}); + +// ─── setMatrix() ───────────────────────────────────────────────────────────── + +describe('StateStore.setMatrix()', () => { + beforeEach(() => { + setupHooksGlobal(); + }); + afterEach(() => { + cleanupHooksGlobal(); + }); + + it('replaces the entire matrix from a valid snapshot', () => { + const store = new StateStore(makeSettings()); + store.setMatrix(STATE_STORE_SNAPSHOTS.threeParticipants); + expect(store.getMatrix().matrix).toEqual(STATE_STORE_SNAPSHOTS.threeParticipants.matrix); + }); + + it('persists via settings.set', () => { + const settings = makeSettings(); + const store = new StateStore(settings); + store.setMatrix(STATE_STORE_SNAPSHOTS.threeParticipants); + expect(settings.set).toHaveBeenCalledWith( + VISIBILITY_MATRIX_KEY, + expect.objectContaining({ matrix: STATE_STORE_SNAPSHOTS.threeParticipants.matrix }), + ); + }); + + it('emits scrying-pool:stateChanged via Hooks.callAll with full matrix', () => { + const store = new StateStore(makeSettings()); + store.setMatrix(STATE_STORE_SNAPSHOTS.threeParticipants); + expect(globalThis.Hooks.callAll).toHaveBeenCalledWith( + 'scrying-pool:stateChanged', + expect.objectContaining({ matrix: expect.any(Object), revision: 1 }), + ); + }); + + it('throws TypeError on invalid matrix', () => { + const store = new StateStore(makeSettings()); + expect(() => store.setMatrix({ _version: 1, matrix: { 'user-1': 'INVALID' } })).toThrow(TypeError); + }); + + it('deep-copies input — external mutations do not affect internal state', () => { + const store = new StateStore(makeSettings()); + const input = { _version: 1, matrix: { 'user-1': 'active' } }; + store.setMatrix(input); + input.matrix['user-1'] = 'hidden'; + expect(store.getState('user-1')).toBe('active'); + }); +}); diff --git a/tests/unit/foundry/FoundryAdapter.test.js b/tests/unit/foundry/FoundryAdapter.test.js index 868ea41..f488fab 100644 --- a/tests/unit/foundry/FoundryAdapter.test.js +++ b/tests/unit/foundry/FoundryAdapter.test.js @@ -3,6 +3,7 @@ * tests/unit/foundry/FoundryAdapter.test.js * * Story 1.2 — WebRTC spike: FoundryAdapter probe tests. + * Story 1.3 — Data layer: constructor(game), surface delegation tests. * * OQ-1 spike result: css-fallback (FoundryVTT v14, 2026-05-21) * track.enabled = false does NOT stop inbound WebRTC bandwidth. @@ -11,7 +12,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { FoundryAdapter } from '../../../src/foundry/FoundryAdapter.js'; -import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js'; +import { + GAME_STUB, + SETTINGS_STUB, + SOCKET_STUB, + USERS_STUB, + SCENES_STUB, + HOOKS_STUB, + UI_STUB, + GM_USER, + PLAYER_USER, + ACTIVE_SCENE, +} from '../../fixtures/foundry-adapter.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -184,12 +196,26 @@ describe('FoundryAdapter.buildWebRTCSurface', () => { // ─── Constructor + interface shape parity ───────────────────────────────────── describe('FoundryAdapter constructor', () => { - it('is side-effect-free — instantiates without accessing game.*', () => { + beforeEach(() => { + vi.stubGlobal('Hooks', HOOKS_STUB); + vi.stubGlobal('ui', UI_STUB); + vi.clearAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('is side-effect-free — instantiates without accessing game.* (no game arg)', () => { expect(() => new FoundryAdapter()).not.toThrow(); }); + it('accepts game as constructor arg and stores it', () => { + const adapter = new FoundryAdapter(GAME_STUB); + expect(adapter._game).toBe(GAME_STUB); + }); + it('has webrtc = null by default (css-fallback path)', () => { - const adapter = new FoundryAdapter(); + const adapter = new FoundryAdapter(GAME_STUB); expect(adapter.webrtc).toBeNull(); }); @@ -199,26 +225,154 @@ describe('FoundryAdapter constructor', () => { expect(typeof FoundryAdapter.SETTING_WEBRTC_MODE).toBe('string'); expect(FoundryAdapter.SETTING_WEBRTC_MODE).toBe('webrtcMode'); }); -}); -describe('Interface shape parity with canonical mock', () => { - it('canonical mock webrtc default matches FoundryAdapter default (null)', () => { - const mock = createFoundryAdapterMock(); - const adapter = new FoundryAdapter(); - expect(mock.webrtc).toBe(adapter.webrtc); // both null - }); - - it('canonical mock can override webrtc to simulate track-disable surface', () => { - const mockSurface = { disableTrack: vi.fn(), enableTrack: vi.fn() }; - const mock = createFoundryAdapterMock({ webrtc: mockSurface }); - expect(mock.webrtc).toBe(mockSurface); - expect(typeof mock.webrtc.disableTrack).toBe('function'); - expect(typeof mock.webrtc.enableTrack).toBe('function'); - }); - - it('buildWebRTCSurface returns object with disableTrack and enableTrack', () => { - const surface = FoundryAdapter.buildWebRTCSurface(makeGameWebrtc()); - expect(typeof surface.disableTrack).toBe('function'); - expect(typeof surface.enableTrack).toBe('function'); + it('exposes all 6 surfaces: settings, socket, users, scenes, notifications, hooks', () => { + const adapter = new FoundryAdapter(GAME_STUB); + expect(typeof adapter.settings).toBe('object'); + expect(typeof adapter.socket).toBe('object'); + expect(typeof adapter.users).toBe('object'); + expect(typeof adapter.scenes).toBe('object'); + expect(typeof adapter.notifications).toBe('object'); + expect(typeof adapter.hooks).toBe('object'); + }); +}); + +// ─── Surface delegation ──────────────────────────────────────────────────────── + +describe('FoundryAdapter surface delegation', () => { + let adapter; + + beforeEach(() => { + vi.stubGlobal('Hooks', HOOKS_STUB); + vi.stubGlobal('ui', UI_STUB); + vi.clearAllMocks(); + adapter = new FoundryAdapter(GAME_STUB); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('settings surface', () => { + it('settings.register delegates to game.settings.register with namespace', () => { + adapter.settings.register('visibilityMatrix', { default: {} }); + expect(SETTINGS_STUB.register).toHaveBeenCalledWith('scrying-pool', 'visibilityMatrix', { default: {} }); + }); + + it('settings.get delegates to game.settings.get with namespace', () => { + SETTINGS_STUB.get.mockReturnValue({ userId1: 'active' }); + const result = adapter.settings.get('visibilityMatrix'); + expect(SETTINGS_STUB.get).toHaveBeenCalledWith('scrying-pool', 'visibilityMatrix'); + expect(result).toEqual({ userId1: 'active' }); + }); + + it('settings.set delegates to game.settings.set with namespace', async () => { + SETTINGS_STUB.set.mockResolvedValue({ userId1: 'hidden' }); + await adapter.settings.set('visibilityMatrix', { userId1: 'hidden' }); + expect(SETTINGS_STUB.set).toHaveBeenCalledWith('scrying-pool', 'visibilityMatrix', { userId1: 'hidden' }); + }); + }); + + describe('socket surface', () => { + it('socket.emit delegates to game.socket.emit', () => { + adapter.socket.emit('scrying-pool.visibility.set', { opId: 'op-1' }); + expect(SOCKET_STUB.emit).toHaveBeenCalledWith('scrying-pool.visibility.set', { opId: 'op-1' }); + }); + + it('socket.on delegates to game.socket.on', () => { + const handler = vi.fn(); + adapter.socket.on('scrying-pool.visibility.set', handler); + expect(SOCKET_STUB.on).toHaveBeenCalledWith('scrying-pool.visibility.set', handler); + }); + + it('socket.off delegates to game.socket.off', () => { + const handler = vi.fn(); + adapter.socket.off('scrying-pool.visibility.set', handler); + expect(SOCKET_STUB.off).toHaveBeenCalledWith('scrying-pool.visibility.set', handler); + }); + }); + + describe('users surface', () => { + it('users.get returns user by id', () => { + const result = adapter.users.get(GM_USER.id); + expect(USERS_STUB.get).toHaveBeenCalledWith(GM_USER.id); + expect(result).toBe(GM_USER); + }); + + it('users.get returns null for unknown id', () => { + expect(adapter.users.get('unknown-id')).toBeNull(); + }); + + it('users.all returns array from game.users iteration', () => { + const result = adapter.users.all(); + expect(Array.isArray(result)).toBe(true); + expect(result).toContain(GM_USER); + expect(result).toContain(PLAYER_USER); + }); + + it('users.isGM(id) checks a specific user', () => { + expect(adapter.users.isGM(GM_USER.id)).toBe(true); + expect(adapter.users.isGM(PLAYER_USER.id)).toBe(false); + }); + + it('users.isGM() with no arg checks current user', () => { + expect(adapter.users.isGM()).toBe(true); // USER_STUB is GM + }); + + it('users.current() returns game.user', () => { + expect(adapter.users.current()).toEqual({ id: GM_USER.id, name: GM_USER.name, isGM: true }); + }); + }); + + describe('scenes surface', () => { + it('scenes.current() returns game.scenes.active', () => { + expect(adapter.scenes.current()).toBe(ACTIVE_SCENE); + }); + + it('scenes.get(id) delegates to game.scenes.get', () => { + const result = adapter.scenes.get(ACTIVE_SCENE.id); + expect(SCENES_STUB.get).toHaveBeenCalledWith(ACTIVE_SCENE.id); + expect(result).toBe(ACTIVE_SCENE); + }); + + it('scenes.get returns null for unknown id', () => { + expect(adapter.scenes.get('no-such-scene')).toBeNull(); + }); + }); + + describe('notifications surface', () => { + it('notifications.info delegates to ui.notifications.info', () => { + adapter.notifications.info('Test info'); + expect(UI_STUB.notifications.info).toHaveBeenCalledWith('Test info'); + }); + + it('notifications.warn delegates to ui.notifications.warn', () => { + adapter.notifications.warn('Test warn'); + expect(UI_STUB.notifications.warn).toHaveBeenCalledWith('Test warn'); + }); + + it('notifications.error delegates to ui.notifications.error', () => { + adapter.notifications.error('Test error'); + expect(UI_STUB.notifications.error).toHaveBeenCalledWith('Test error'); + }); + }); + + describe('hooks surface', () => { + it('hooks.on delegates to Hooks.on', () => { + const handler = vi.fn(); + adapter.hooks.on('ready', handler); + expect(HOOKS_STUB.on).toHaveBeenCalledWith('ready', handler); + }); + + it('hooks.once delegates to Hooks.once', () => { + const handler = vi.fn(); + adapter.hooks.once('ready', handler); + expect(HOOKS_STUB.once).toHaveBeenCalledWith('ready', handler); + }); + + it('hooks.off delegates to Hooks.off', () => { + const handler = vi.fn(); + adapter.hooks.off('ready', handler); + expect(HOOKS_STUB.off).toHaveBeenCalledWith('ready', handler); + }); }); });