Fix Story 1.3: StateStore spec compliance and minor cleanup
Critical Fix: - StateStore now uses global Hooks.callAll directly (per spec) - Removed hooks parameter from StateStore constructor - Updated module.js to pass only adapter.settings - Updated tests to stub globalThis.Hooks Minor Cleanup: - Fixed misleading warning in SocketHandler.registerPendingOp - Added clarifying comment for setMatrix _revision behavior Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
+968
@@ -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<unknown>} */
|
||||||
|
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<unknown> }} 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<string, string> }}
|
||||||
|
*/
|
||||||
|
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<string, string> }} 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<string, import('../contracts/pending-op.js').PendingOp>} */
|
||||||
|
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
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
||||||
|
|
||||||
generated: "2026-05-21T01:00:00+02:00"
|
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: video-view-manager
|
||||||
project_key: NOKEY
|
project_key: NOKEY
|
||||||
tracking_system: file-system
|
tracking_system: file-system
|
||||||
@@ -46,7 +46,7 @@ development_status:
|
|||||||
epic-1: in-progress
|
epic-1: in-progress
|
||||||
1-1-module-scaffold-cicd-pipeline-and-design-token-system: done
|
1-1-module-scaffold-cicd-pipeline-and-design-token-system: done
|
||||||
1-2-webrtc-spike-track-disabling-api-validation: 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-4-core-logic-scryingpoolcontroller-and-visibilitymanager: backlog
|
||||||
1-5-gm-control-ui-scryingpoolstrip-actionpopover-and-av-tile-integration: backlog
|
1-5-gm-control-ui-scryingpoolstrip-actionpopover-and-av-tile-integration: backlog
|
||||||
1-6-player-camera-status-badge: backlog
|
1-6-player-camera-status-badge: backlog
|
||||||
|
|||||||
@@ -8,11 +8,22 @@
|
|||||||
* Initialisation order:
|
* Initialisation order:
|
||||||
* Hooks.once('init') → register world settings → construct FoundryAdapter
|
* Hooks.once('init') → register world settings → construct FoundryAdapter
|
||||||
* → StateStore → SocketHandler (queue+drain)
|
* → 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
|
* → NotificationBus → RoleRenderer → RosterStrip
|
||||||
* → DirectorsBoard (lazy, GM only)
|
* → 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", () => {
|
Hooks.once("init", () => {
|
||||||
console.log("[ScryingPool] init — module loading");
|
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", () => {
|
Hooks.once("ready", () => {
|
||||||
console.log("[ScryingPool] ready — module active");
|
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)
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@
|
|||||||
* @module contracts/pending-op
|
* @module contracts/pending-op
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @typedef {Object} PendingOp */
|
|
||||||
|
|
||||||
/** Shape version constant for PendingOp. @type {1} */
|
/** Shape version constant for PendingOp. @type {1} */
|
||||||
export const PENDING_OP_VERSION = 1;
|
export const PENDING_OP_VERSION = 1;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Socket message contract.
|
* Socket message contract.
|
||||||
*
|
*
|
||||||
* Two message directions:
|
* 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 }
|
* Echo (all ← GM): scrying-pool.visibility.updated { opId, userId, state, revision }
|
||||||
*
|
*
|
||||||
* Validated at both send and receive. Payload ≥ 4096 bytes → throw before emit.
|
* 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} opId - Unique operation ID (non-empty string).
|
||||||
* @property {string} userId - Target participant userId (non-empty string).
|
* @property {string} userId - Target participant userId (non-empty string).
|
||||||
* @property {string} targetState - Desired VisibilityState.
|
* @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} opId - Unique operation ID.
|
||||||
* @param {string} userId - Target participant userId.
|
* @param {string} userId - Target participant userId.
|
||||||
* @param {string} targetState - Desired VisibilityState.
|
* @param {string} targetState - Desired VisibilityState.
|
||||||
|
* @param {number} baseRevision - Revision counter at time of intent.
|
||||||
* @returns {SocketMessage}
|
* @returns {SocketMessage}
|
||||||
*/
|
*/
|
||||||
export function createSocketIntentMessage(opId, userId, targetState) {
|
export function createSocketIntentMessage(opId, userId, targetState, baseRevision) {
|
||||||
return {
|
return {
|
||||||
event: SOCKET_EVENTS.VISIBILITY_SET,
|
event: SOCKET_EVENTS.VISIBILITY_SET,
|
||||||
payload: { opId, userId, targetState },
|
payload: { opId, userId, targetState, baseRevision },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +97,7 @@ export function isValidSocketMessage(data) {
|
|||||||
const p = /** @type {Record<string, unknown>} */ (payload);
|
const p = /** @type {Record<string, unknown>} */ (payload);
|
||||||
// Validate intent payload
|
// Validate intent payload
|
||||||
if (event === SOCKET_EVENTS.VISIBILITY_SET) {
|
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) {
|
if (Object.keys(payloadRest).length > 0) {
|
||||||
throw new TypeError(`SocketMessage intent: unknown payload keys: ${Object.keys(payloadRest).join(", ")}`);
|
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) {
|
if (typeof targetState !== "string" || targetState.length === 0) {
|
||||||
throw new TypeError("SocketMessage: targetState must be a non-empty string");
|
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
|
// Validate echo payload
|
||||||
if (event === SOCKET_EVENTS.VISIBILITY_UPDATED) {
|
if (event === SOCKET_EVENTS.VISIBILITY_UPDATED) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
* @typedef {Object} VisibilityMatrix
|
* @typedef {Object} VisibilityMatrix
|
||||||
* @property {1} _version - Schema version; always 1 for v1.
|
* @property {1} _version - Schema version; always 1 for v1.
|
||||||
* @property {Object.<string, VisibilityState>} matrix - userId → VisibilityState map.
|
* @property {Object.<string, VisibilityState>} matrix - userId → VisibilityState map.
|
||||||
|
* @property {number} [_revision] - Optional revision counter for optimistic concurrency control.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const VISIBILITY_STATES = Object.freeze([
|
export const VISIBILITY_STATES = Object.freeze([
|
||||||
@@ -50,7 +51,7 @@ export function isValidVisibilityMatrix(data) {
|
|||||||
throw new TypeError("VisibilityMatrix: must be an object");
|
throw new TypeError("VisibilityMatrix: must be an object");
|
||||||
}
|
}
|
||||||
const obj = /** @type {Record<string, unknown>} */ (data);
|
const obj = /** @type {Record<string, unknown>} */ (data);
|
||||||
const { _version, matrix, ...rest } = obj;
|
const { _version, matrix, _revision, ...rest } = obj;
|
||||||
if (Object.keys(rest).length > 0) {
|
if (Object.keys(rest).length > 0) {
|
||||||
throw new TypeError(`VisibilityMatrix: unknown keys: ${Object.keys(rest).join(", ")}`);
|
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)) {
|
if (matrix === null || typeof matrix !== "object" || Array.isArray(matrix)) {
|
||||||
throw new TypeError("VisibilityMatrix: matrix must be a plain object");
|
throw new TypeError("VisibilityMatrix: matrix must be a plain object");
|
||||||
}
|
}
|
||||||
|
if (_revision !== undefined && (typeof _revision !== "number" || !Number.isFinite(_revision) || _revision < 0)) {
|
||||||
|
throw new TypeError("VisibilityMatrix: _revision must be a finite non-negative number");
|
||||||
|
}
|
||||||
const matrixObj = /** @type {Record<string, unknown>} */ (matrix);
|
const matrixObj = /** @type {Record<string, unknown>} */ (matrix);
|
||||||
for (const [userId, state] of Object.entries(matrixObj)) {
|
for (const [userId, state] of Object.entries(matrixObj)) {
|
||||||
if (typeof userId !== "string" || userId.length === 0) {
|
if (typeof userId !== "string" || userId.length === 0) {
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* SocketHandler — queued, timeout-aware socket emit with PendingOp lifecycle.
|
||||||
|
*
|
||||||
|
* Wraps the adapter.socket surface to provide:
|
||||||
|
* - Readiness queue (messages emitted before setReady are drained at setReady time)
|
||||||
|
* - PendingOp registration + confirmation (opId keyed, 3s timeout, one retry, revert)
|
||||||
|
* - Payload size guard (throws on payload ≥ MAX_PAYLOAD_BYTES)
|
||||||
|
*
|
||||||
|
* Import rule: may only import from src/contracts/ and src/utils/.
|
||||||
|
* No game.* access — all FoundryVTT interaction flows through injected surfaces.
|
||||||
|
* Throws errors — callers are responsible for catching.
|
||||||
|
*
|
||||||
|
* @module core/SocketHandler
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MAX_PAYLOAD_BYTES } from '../contracts/socket-message.js';
|
||||||
|
|
||||||
|
// Maximum queued messages before we start dropping (prevents unbounded memory growth)
|
||||||
|
export const MAX_QUEUE_SIZE = 1000;
|
||||||
|
|
||||||
|
export const SOCKET_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queued socket handler with PendingOp lifecycle management (timeout, retry, revert).
|
||||||
|
*/
|
||||||
|
export class SocketHandler {
|
||||||
|
/**
|
||||||
|
* @param {{ emit(event: string, payload: object): void, on(event: string, handler: Function): void, off(event: string, handler: Function): void }} socket
|
||||||
|
* Injected adapter.socket surface.
|
||||||
|
* @param {{ on(event: string, handler: Function): void, once(event: string, handler: Function): void }} hooks
|
||||||
|
* Injected adapter.hooks surface (reserved for Story 1.4 hook-driven flows).
|
||||||
|
*/
|
||||||
|
constructor(socket, hooks) {
|
||||||
|
if (!socket || typeof socket !== 'object' || typeof socket.emit !== 'function') {
|
||||||
|
throw new TypeError('SocketHandler: socket argument is required and must have an emit method');
|
||||||
|
}
|
||||||
|
if (!hooks || typeof hooks !== 'object') {
|
||||||
|
throw new TypeError('SocketHandler: hooks argument is required and must be an object');
|
||||||
|
}
|
||||||
|
this._socket = socket;
|
||||||
|
this._hooks = hooks;
|
||||||
|
/** @type {Array<{event: string, payload: object}>} */
|
||||||
|
this._messageQueue = [];
|
||||||
|
this._isReady = false;
|
||||||
|
/** @type {Map<string, Record<string, any>>} opId → PendingOp */
|
||||||
|
this._pendingOps = new Map();
|
||||||
|
/** @type {{ onRevert(pendingOp: object): void } | null} */
|
||||||
|
this._handler = null;
|
||||||
|
/** @type {number} */
|
||||||
|
this._droppedCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the handler as ready, stores the revert callback, and drains the queue.
|
||||||
|
* Null handler: logs a warning and proceeds — safe to call with null.
|
||||||
|
* In Story 1.3, module.js does NOT call setReady — that is Story 1.4's responsibility.
|
||||||
|
* @param {{ onRevert(pendingOp: object): void } | null} handler
|
||||||
|
*/
|
||||||
|
setReady(handler) {
|
||||||
|
if (!handler) {
|
||||||
|
console.warn('[ScryingPool] SocketHandler.setReady: handler is null — revert callbacks will be skipped');
|
||||||
|
}
|
||||||
|
this._isReady = true;
|
||||||
|
this._handler = handler;
|
||||||
|
this._drainQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the handler, clearing all pending operations and timers.
|
||||||
|
* Should be called when the handler is no longer needed to prevent memory leaks.
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
for (const [, op] of this._pendingOps) {
|
||||||
|
if (op.timeoutId) {
|
||||||
|
clearTimeout(op.timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._pendingOps.clear();
|
||||||
|
this._messageQueue = [];
|
||||||
|
this._isReady = false;
|
||||||
|
this._handler = null;
|
||||||
|
this._droppedCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits an event on the socket. If not yet ready, queues the message for later.
|
||||||
|
* @param {string} event
|
||||||
|
* @param {object} payload
|
||||||
|
* @throws {Error} if payload JSON exceeds MAX_PAYLOAD_BYTES (4096 bytes)
|
||||||
|
* @throws {TypeError} if event is not a non-empty string
|
||||||
|
*/
|
||||||
|
emit(event, payload) {
|
||||||
|
if (typeof event !== 'string' || event.length === 0) {
|
||||||
|
throw new TypeError('SocketHandler: event must be a non-empty string');
|
||||||
|
}
|
||||||
|
let encoded;
|
||||||
|
try {
|
||||||
|
encoded = JSON.stringify(payload);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`[ScryingPool] SocketHandler: payload is not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
if (encoded.length >= MAX_PAYLOAD_BYTES) {
|
||||||
|
throw new Error(`[ScryingPool] SocketHandler: payload exceeds ${MAX_PAYLOAD_BYTES} bytes (${encoded.length})`);
|
||||||
|
}
|
||||||
|
if (!this._isReady) {
|
||||||
|
// Drop if queue is full to prevent unbounded memory growth
|
||||||
|
if (this._messageQueue.length >= MAX_QUEUE_SIZE) {
|
||||||
|
this._droppedCount++;
|
||||||
|
if (this._droppedCount === 1 || this._droppedCount % 100 === 0) {
|
||||||
|
console.warn(`[ScryingPool] SocketHandler: dropped ${this._droppedCount} queued messages due to queue overflow`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._messageQueue.push({ event, payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._socket.emit(event, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a PendingOp and starts its 3-second acknowledgement timeout.
|
||||||
|
* Throws if pendingOp is null/undefined or missing opId.
|
||||||
|
* Warns and replaces if opId already exists (leaking previous timer).
|
||||||
|
* @param {Record<string, any>} pendingOp - Must have at least `{ opId: string }`.
|
||||||
|
* @param {string} [event] - Optional socket event name for retry.
|
||||||
|
* @param {object} [payload] - Optional payload for retry.
|
||||||
|
* @throws {TypeError} if pendingOp is null/undefined or missing opId
|
||||||
|
*/
|
||||||
|
registerPendingOp(pendingOp, event, payload) {
|
||||||
|
if (!pendingOp || typeof pendingOp !== 'object') {
|
||||||
|
throw new TypeError('SocketHandler.registerPendingOp: pendingOp must be an object');
|
||||||
|
}
|
||||||
|
if (typeof pendingOp.opId !== 'string' || pendingOp.opId.length === 0) {
|
||||||
|
throw new TypeError('SocketHandler.registerPendingOp: pendingOp must have a non-empty opId string');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if overwriting existing op (previous timer is cleared below)
|
||||||
|
if (this._pendingOps.has(pendingOp.opId)) {
|
||||||
|
const existingEntry = /** @type {{ pendingOp: Record<string, any>, timeoutId: number, event?: string, payload?: object }} */ (this._pendingOps.get(pendingOp.opId));
|
||||||
|
console.warn('[ScryingPool] SocketHandler: overwriting existing pending op', existingEntry.pendingOp.opId);
|
||||||
|
clearTimeout(existingEntry.timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create timeout and store WITHOUT mutating the original pendingOp
|
||||||
|
const timeoutId = setTimeout(() => this._onTimeout(pendingOp.opId, false), SOCKET_TIMEOUT_MS);
|
||||||
|
this._pendingOps.set(pendingOp.opId, { pendingOp, event, payload, timeoutId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirms a PendingOp by opId — clears its timer and removes it from the map.
|
||||||
|
* Unknown/stale opId: discards silently (no error).
|
||||||
|
* @param {string} opId
|
||||||
|
*/
|
||||||
|
confirmPendingOp(opId) {
|
||||||
|
const entry = this._pendingOps.get(opId);
|
||||||
|
if (!entry) return; // stale echo — discard silently
|
||||||
|
if (entry.timeoutId) {
|
||||||
|
clearTimeout(entry.timeoutId);
|
||||||
|
}
|
||||||
|
this._pendingOps.delete(opId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_drainQueue() {
|
||||||
|
for (const { event, payload } of this._messageQueue) {
|
||||||
|
this._socket.emit(event, payload);
|
||||||
|
}
|
||||||
|
this._messageQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to remove internal timeoutId field from an object.
|
||||||
|
* @private
|
||||||
|
* @param {object} obj
|
||||||
|
* @returns {object} Object without timeoutId field
|
||||||
|
*/
|
||||||
|
_stripTimeoutId(obj) {
|
||||||
|
if (!obj || typeof obj !== 'object') return obj;
|
||||||
|
// eslint-disable-next-line no-unused-vars -- intentionally removing timeoutId field
|
||||||
|
const { timeoutId, ...rest } = /** @type {{ timeoutId?: number, [key: string]: any }} */ (obj);
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @param {string} opId
|
||||||
|
* @param {boolean} isRetry - true if this is the second timeout (after one retry attempt)
|
||||||
|
*/
|
||||||
|
_onTimeout(opId, isRetry) {
|
||||||
|
const entry = this._pendingOps.get(opId);
|
||||||
|
if (!entry) return; // already confirmed — stale timer, ignore
|
||||||
|
|
||||||
|
if (!isRetry) {
|
||||||
|
// First timeout: retry once
|
||||||
|
// Use stored event/payload if provided; otherwise use pendingOp minus timeoutId
|
||||||
|
if (this._isReady) {
|
||||||
|
try {
|
||||||
|
if (entry.event && entry.payload) {
|
||||||
|
// Use stored event and payload (explicit, clean)
|
||||||
|
this._socket.emit(entry.event, this._stripTimeoutId(entry.payload));
|
||||||
|
} else if (entry.pendingOp) {
|
||||||
|
// Fallback: use pendingOp minus timeoutId
|
||||||
|
// Use a sensible default event if not stored
|
||||||
|
this._socket.emit('scrying-pool.visibility.set', this._stripTimeoutId(entry.pendingOp));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore emit error on retry — the revert path handles final failure
|
||||||
|
console.warn('[ScryingPool] SocketHandler: retry emit failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const retryTimeoutId = setTimeout(() => this._onTimeout(opId, true), SOCKET_TIMEOUT_MS);
|
||||||
|
entry.timeoutId = retryTimeoutId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second timeout — give up: warn + call handler.onRevert with the original pendingOp
|
||||||
|
this._pendingOps.delete(opId);
|
||||||
|
console.warn('[ScryingPool] SocketHandler: unacknowledged op after retry, reverting', opId);
|
||||||
|
if (!this._handler) return;
|
||||||
|
this._handler.onRevert(entry.pendingOp);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* StateStore — in-memory visibility matrix with persistence and change events.
|
||||||
|
*
|
||||||
|
* Sole keeper of the visibility state for all participants in the current session.
|
||||||
|
* Persists state to `adapter.settings` ('visibilityMatrix') and emits
|
||||||
|
* `scrying-pool:stateChanged` via Hooks.callAll on every mutation.
|
||||||
|
*
|
||||||
|
* Import rule: may only import from src/contracts/ and src/utils/.
|
||||||
|
* Throws errors — callers (module.js / src/foundry/) are responsible for catching.
|
||||||
|
*
|
||||||
|
* @module core/StateStore
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { VISIBILITY_STATES, isValidVisibilityMatrix } from '../contracts/visibility-matrix.js';
|
||||||
|
|
||||||
|
export const VISIBILITY_MATRIX_KEY = 'visibilityMatrix';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory visibility matrix with settings persistence and hooks event emission.
|
||||||
|
* Uses Hooks.callAll directly (Hooks is a FoundryVTT standalone global).
|
||||||
|
*/
|
||||||
|
export class StateStore {
|
||||||
|
/**
|
||||||
|
* @param {{ get(key: string): unknown, set(key: string, value: unknown): Promise<unknown> }} settings
|
||||||
|
* Injected adapter.settings surface (already namespaced — pass short keys only).
|
||||||
|
*/
|
||||||
|
constructor(settings) {
|
||||||
|
if (!settings || typeof settings !== 'object') {
|
||||||
|
throw new TypeError('StateStore: settings argument is required and must be an object');
|
||||||
|
}
|
||||||
|
this._settings = settings;
|
||||||
|
/** @type {Record<string, string>} userId → VisibilityState */
|
||||||
|
this._matrix = {};
|
||||||
|
this._version = 1;
|
||||||
|
this._revision = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrates the in-memory matrix from the persisted world setting.
|
||||||
|
* Called from module.js Hooks.once('ready'). Idempotent — last call wins.
|
||||||
|
* On corrupt/missing data: logs warning and starts fresh (does not throw).
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
try {
|
||||||
|
const raw = this._settings.get(VISIBILITY_MATRIX_KEY);
|
||||||
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||||
|
const validated = isValidVisibilityMatrix(raw);
|
||||||
|
this._matrix = { ...validated.matrix };
|
||||||
|
this._version = validated._version ?? 1;
|
||||||
|
this._revision = validated._revision ?? 0;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TypeError) {
|
||||||
|
console.warn('[ScryingPool] StateStore.init: invalid matrix data, starting fresh', err.message);
|
||||||
|
} else {
|
||||||
|
console.warn('[ScryingPool] StateStore.init: could not hydrate matrix, starting fresh');
|
||||||
|
}
|
||||||
|
this._matrix = {};
|
||||||
|
this._revision = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current VisibilityState for a participant, or null if unknown.
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
getState(userId) {
|
||||||
|
return this._matrix[userId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a deep snapshot of the current visibility matrix.
|
||||||
|
* Mutations to the returned object do not affect internal state.
|
||||||
|
* @returns {{ _version: number, matrix: Record<string, string> }}
|
||||||
|
*/
|
||||||
|
getMatrix() {
|
||||||
|
return { _version: this._version, matrix: { ...this._matrix } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a single participant's visibility state, persists, and emits hook.
|
||||||
|
* No-op if userId is empty/non-string or targetState is not a valid VISIBILITY_STATE.
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {string} targetState - Must be a value in VISIBILITY_STATES.
|
||||||
|
* @returns {Promise<unknown>|undefined} The persistence promise from settings.set, or undefined for no-op.
|
||||||
|
*/
|
||||||
|
setVisibility(userId, targetState) {
|
||||||
|
if (!userId || typeof userId !== 'string') return undefined;
|
||||||
|
if (!VISIBILITY_STATES.includes(targetState)) return undefined;
|
||||||
|
|
||||||
|
const previousState = this._matrix[userId] ?? null;
|
||||||
|
this._matrix[userId] = targetState;
|
||||||
|
this._revision++;
|
||||||
|
|
||||||
|
const persistencePromise = this._settings.set(VISIBILITY_MATRIX_KEY, {
|
||||||
|
_version: this._version,
|
||||||
|
_revision: this._revision,
|
||||||
|
matrix: { ...this._matrix },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire hook immediately (per AC-2), but catch errors to prevent hook failures
|
||||||
|
// from blocking state mutation. Persistence is async and may complete after.
|
||||||
|
try {
|
||||||
|
Hooks.callAll('scrying-pool:stateChanged', {
|
||||||
|
userId,
|
||||||
|
state: targetState,
|
||||||
|
previousState,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
revision: this._revision,
|
||||||
|
});
|
||||||
|
} catch (hookErr) {
|
||||||
|
console.error('[ScryingPool] StateStore.setVisibility: hook emission failed', hookErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return persistencePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the entire matrix (used by preset apply and scene restore).
|
||||||
|
* Throws TypeError on invalid matrix — src/core throws, callers catch.
|
||||||
|
* @param {{ _version: number, _revision?: number, matrix: Record<string, string> }} matrix
|
||||||
|
* @returns {Promise<unknown>} The persistence promise from settings.set.
|
||||||
|
*/
|
||||||
|
setMatrix(matrix) {
|
||||||
|
const validated = isValidVisibilityMatrix(matrix);
|
||||||
|
this._matrix = { ...validated.matrix };
|
||||||
|
this._version = validated._version ?? 1;
|
||||||
|
this._revision++; // Always increment from current value; input _revision is informational only
|
||||||
|
|
||||||
|
const persistencePromise = this._settings.set(VISIBILITY_MATRIX_KEY, {
|
||||||
|
_version: this._version,
|
||||||
|
_revision: this._revision,
|
||||||
|
matrix: { ...this._matrix },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire hook immediately, but catch errors
|
||||||
|
try {
|
||||||
|
Hooks.callAll('scrying-pool:stateChanged', {
|
||||||
|
matrix: this.getMatrix(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
revision: this._revision,
|
||||||
|
});
|
||||||
|
} catch (hookErr) {
|
||||||
|
console.error('[ScryingPool] StateStore.setMatrix: hook emission failed', hookErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return persistencePromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
-14
@@ -16,13 +16,12 @@
|
|||||||
/**
|
/**
|
||||||
* Sole gateway to game.* APIs for the Scrying Pool module.
|
* Sole gateway to game.* APIs for the Scrying Pool module.
|
||||||
*
|
*
|
||||||
* Feature-detects WebRTC track-disabling capability at init time via
|
* Exposes typed surfaces for: settings, socket, users, scenes, notifications, hooks, webrtc.
|
||||||
* {@link FoundryAdapter.probeCapability}. Exposes a `webrtc` surface
|
* All surfaces are built in the constructor — construction is side-effect-free.
|
||||||
* ({disableTrack, enableTrack}) if track-disable is confirmed; `null` otherwise.
|
|
||||||
*
|
*
|
||||||
* Construction is side-effect-free. The webrtc property must be set by the
|
* The `webrtc` property starts as null and must be set externally at
|
||||||
* caller (at Hooks.once('ready') when game.webrtc is available) via
|
* Hooks.once('ready') via {@link FoundryAdapter.probeCapability} and
|
||||||
* {@link FoundryAdapter.buildWebRTCSurface}. Story 1.3 wires this up.
|
* {@link FoundryAdapter.buildWebRTCSurface}.
|
||||||
*/
|
*/
|
||||||
export class FoundryAdapter {
|
export class FoundryAdapter {
|
||||||
/** Settings namespace for all scrying-pool world settings. */
|
/** Settings namespace for all scrying-pool world settings. */
|
||||||
@@ -36,19 +35,116 @@ export class FoundryAdapter {
|
|||||||
static SETTING_WEBRTC_MODE = 'webrtcMode';
|
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.
|
* WebRTC track-disabling surface, or null on the css-fallback/unsupported path.
|
||||||
*
|
* Set externally at ready time via probeCapability + buildWebRTCSurface.
|
||||||
* Set to `{ disableTrack, enableTrack }` only if probeCapability returns
|
|
||||||
* `'track-disable'`. As of FoundryVTT v14, the probe always returns
|
|
||||||
* `'css-fallback'` or `'unsupported'` — see OQ-1 spike comment at top of file.
|
|
||||||
*
|
|
||||||
* @type {{ disableTrack(userId: string): void, enableTrack(userId: string): void } | null}
|
* @type {{ disableTrack(userId: string): void, enableTrack(userId: string): void } | null}
|
||||||
*/
|
*/
|
||||||
this.webrtc = null;
|
this.webrtc = null;
|
||||||
|
|
||||||
|
const g = /** @type {any} */ (this._game);
|
||||||
|
const ns = FoundryAdapter.SETTINGS_NS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings surface — wraps game.settings, pre-namespaced to 'scrying-pool'.
|
||||||
|
* Keys passed to these methods are the SHORT key (e.g. 'visibilityMatrix'),
|
||||||
|
* NOT the fully-qualified key ('scrying-pool.visibilityMatrix').
|
||||||
|
*/
|
||||||
|
this.settings = {
|
||||||
|
/** @param {string} key @param {object} config */
|
||||||
|
register: (key, config) => g.settings?.register(ns, key, config),
|
||||||
|
/** @param {string} key @returns {unknown} */
|
||||||
|
get: (key) => g.settings?.get(ns, key) ?? null,
|
||||||
|
/** @param {string} key @param {unknown} value @returns {Promise<unknown>} */
|
||||||
|
set: (key, value) => /** @type {Promise<unknown>} */ (g.settings?.set(ns, key, value) ?? Promise.resolve()),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Socket surface — wraps game.socket. */
|
||||||
|
this.socket = {
|
||||||
|
/** @param {string} event @param {object} payload @returns {void|undefined} */
|
||||||
|
emit: (event, payload) => {
|
||||||
|
if (g.socket) return g.socket.emit(event, payload);
|
||||||
|
},
|
||||||
|
/** @param {string} event @param {Function} handler @returns {void|undefined} */
|
||||||
|
on: (event, handler) => {
|
||||||
|
if (g.socket) return g.socket.on(event, handler);
|
||||||
|
},
|
||||||
|
/** @param {string} event @param {Function} handler @returns {void|undefined} */
|
||||||
|
off: (event, handler) => {
|
||||||
|
if (g.socket) return g.socket.off(event, handler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Users surface — wraps game.users / game.user. */
|
||||||
|
this.users = {
|
||||||
|
/** @param {string} userId @returns {object|null} */
|
||||||
|
get: (userId) => g.users?.get(userId) ?? null,
|
||||||
|
/** @returns {object[]} */
|
||||||
|
all: () => Array.from(g.users ?? []),
|
||||||
|
/**
|
||||||
|
* @param {string} [userId] - If omitted or null/undefined, checks current user.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isGM: (userId) => {
|
||||||
|
if (userId == null) {
|
||||||
|
// null or undefined: check current user
|
||||||
|
return (g.user?.isGM ?? false);
|
||||||
|
}
|
||||||
|
// Explicit userId (including "0" or ""): look up in users map
|
||||||
|
return (g.users?.get(userId)?.isGM ?? false);
|
||||||
|
},
|
||||||
|
/** @returns {object|null} */
|
||||||
|
current: () => g.user ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Scenes surface — wraps game.scenes. */
|
||||||
|
this.scenes = {
|
||||||
|
/** @returns {object|null} The currently active scene. */
|
||||||
|
current: () => g.scenes?.active ?? null,
|
||||||
|
/** @param {string} id @returns {object|null} */
|
||||||
|
get: (id) => g.scenes?.get(id) ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifications surface — wraps ui.notifications.
|
||||||
|
* Errors are caught silently — notification failures must never crash visibility logic.
|
||||||
|
*/
|
||||||
|
this.notifications = {
|
||||||
|
/** @param {string} msg */
|
||||||
|
info: (msg) => { try { ui.notifications?.info(msg); } catch (err) { console.warn('[ScryingPool] notifications.info:', err); } },
|
||||||
|
/** @param {string} msg */
|
||||||
|
warn: (msg) => { try { ui.notifications?.warn(msg); } catch (err) { console.warn('[ScryingPool] notifications.warn:', err); } },
|
||||||
|
/** @param {string} msg */
|
||||||
|
error: (msg) => { try { ui.notifications?.error(msg); } catch (err) { console.warn('[ScryingPool] notifications.error:', err); } },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hooks surface — wraps FoundryVTT Hooks global. */
|
||||||
|
this.hooks = {
|
||||||
|
/** @param {string} event @param {(...args: unknown[]) => void} handler @returns {void|undefined} */
|
||||||
|
on: (event, handler) => {
|
||||||
|
if (typeof Hooks !== 'undefined') return Hooks.on(event, handler);
|
||||||
|
},
|
||||||
|
/** @param {string} event @param {(...args: unknown[]) => void} handler @returns {void|undefined} */
|
||||||
|
once: (event, handler) => {
|
||||||
|
if (typeof Hooks !== 'undefined') return Hooks.once(event, handler);
|
||||||
|
},
|
||||||
|
/** @param {string} event @param {(...args: unknown[]) => void} handler @returns {void|undefined} */
|
||||||
|
off: (event, handler) => {
|
||||||
|
if (typeof Hooks !== 'undefined') return Hooks.off(event, handler);
|
||||||
|
},
|
||||||
|
/** @param {string} event @param {...unknown[]} args @returns {boolean|undefined} */
|
||||||
|
callAll: (event, ...args) => {
|
||||||
|
if (typeof Hooks !== 'undefined') return Hooks.callAll(event, ...args);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +152,7 @@ export class FoundryAdapter {
|
|||||||
*
|
*
|
||||||
* Probe logic and results (FoundryVTT v14, 2026-05-21):
|
* 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 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'`
|
* - If game.webrtc.client lacks getMediaStreamForUser() → non-standard backend → `'unsupported'`
|
||||||
* - Otherwise: tracks are technically reachable but track.enabled = false does NOT reduce
|
* - Otherwise: tracks are technically reachable but track.enabled = false does NOT reduce
|
||||||
* inbound WebRTC bandwidth (RTP packets keep arriving). The `'track-disable'` outcome
|
* inbound WebRTC bandwidth (RTP packets keep arriving). The `'track-disable'` outcome
|
||||||
@@ -68,7 +165,7 @@ export class FoundryAdapter {
|
|||||||
* @returns {'track-disable'|'css-fallback'|'unsupported'}
|
* @returns {'track-disable'|'css-fallback'|'unsupported'}
|
||||||
*/
|
*/
|
||||||
static probeCapability(gameWebrtc) {
|
static probeCapability(gameWebrtc) {
|
||||||
if (!gameWebrtc) return 'unsupported';
|
if (!gameWebrtc || typeof gameWebrtc !== 'object') return 'unsupported';
|
||||||
const client = /** @type {any} */ (gameWebrtc).client;
|
const client = /** @type {any} */ (gameWebrtc).client;
|
||||||
if (!client || typeof client.getMediaStreamForUser !== 'function') return 'unsupported';
|
if (!client || typeof client.getMediaStreamForUser !== 'function') return 'unsupported';
|
||||||
// track.enabled = false on remote inbound tracks does NOT stop WebRTC bandwidth.
|
// 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
|
* @param {{ client: { getMediaStreamForUser(userId: string): (MediaStream|null|undefined) } }} gameWebrtc
|
||||||
* @returns {{ disableTrack(userId: string): void, enableTrack(userId: string): void }}
|
* @returns {{ disableTrack(userId: string): void, enableTrack(userId: string): void }}
|
||||||
|
* @throws {TypeError} if gameWebrtc or gameWebrtc.client is invalid
|
||||||
*/
|
*/
|
||||||
static buildWebRTCSurface(gameWebrtc) {
|
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 {
|
return {
|
||||||
disableTrack(userId) {
|
disableTrack(userId) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Vendored
+8
@@ -10,3 +10,11 @@ declare const Hooks: {
|
|||||||
call(event: string, ...args: unknown[]): boolean;
|
call(event: string, ...args: unknown[]): boolean;
|
||||||
callAll(event: string, ...args: unknown[]): boolean;
|
callAll(event: string, ...args: unknown[]): boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declare const ui: {
|
||||||
|
notifications?: {
|
||||||
|
info(msg: string): void;
|
||||||
|
warn(msg: string): void;
|
||||||
|
error(msg: string): void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
+11
-3
@@ -1,7 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Generates a unique operation ID for PendingOp tracking.
|
* @module utils/uuid
|
||||||
* @returns {string} A unique identifier string.
|
* 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() {
|
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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+94
@@ -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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
Vendored
+47
@@ -15,6 +15,7 @@ export const SOCKET_PAYLOADS = Object.freeze({
|
|||||||
opId: "op-001",
|
opId: "op-001",
|
||||||
userId: "user-abc",
|
userId: "user-abc",
|
||||||
targetState: "hidden",
|
targetState: "hidden",
|
||||||
|
baseRevision: 0,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export const SOCKET_PAYLOADS = Object.freeze({
|
|||||||
payload: Object.freeze({
|
payload: Object.freeze({
|
||||||
userId: "user-abc",
|
userId: "user-abc",
|
||||||
targetState: "hidden",
|
targetState: "hidden",
|
||||||
|
baseRevision: 0,
|
||||||
// opId intentionally omitted
|
// opId intentionally omitted
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -46,6 +48,7 @@ export const SOCKET_PAYLOADS = Object.freeze({
|
|||||||
opId: "op-002",
|
opId: "op-002",
|
||||||
userId: "user-abc",
|
userId: "user-abc",
|
||||||
targetState: "invisible", // not a valid VisibilityState
|
targetState: "invisible", // not a valid VisibilityState
|
||||||
|
baseRevision: 0,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ export const SOCKET_PAYLOADS = Object.freeze({
|
|||||||
opId: "op-003",
|
opId: "op-003",
|
||||||
userId: "user-abc",
|
userId: "user-abc",
|
||||||
targetState: "hidden",
|
targetState: "hidden",
|
||||||
|
baseRevision: 0,
|
||||||
extraField: "should-not-be-here",
|
extraField: "should-not-be-here",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -76,4 +80,47 @@ export const SOCKET_PAYLOADS = Object.freeze({
|
|||||||
// revision intentionally omitted
|
// 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
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import { SOCKET_PAYLOADS } from "../../fixtures/socket-payloads.js";
|
|||||||
describe("socket-message contract", () => {
|
describe("socket-message contract", () => {
|
||||||
describe("createSocketIntentMessage()", () => {
|
describe("createSocketIntentMessage()", () => {
|
||||||
it("creates a valid intent message", () => {
|
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);
|
expect(msg.event).toBe(SOCKET_EVENTS.VISIBILITY_SET);
|
||||||
const p = /** @type {any} */ (msg.payload);
|
const p = /** @type {any} */ (msg.payload);
|
||||||
expect(p.opId).toBe("op-1");
|
expect(p.opId).toBe("op-1");
|
||||||
expect(p.userId).toBe("user-1");
|
expect(p.userId).toBe("user-1");
|
||||||
expect(p.targetState).toBe("hidden");
|
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", () => {
|
it("throws on unknown top-level keys", () => {
|
||||||
expect(() =>
|
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);
|
).toThrow(TypeError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws on missing baseRevision in intent", () => {
|
||||||
|
expect(() => isValidSocketMessage(SOCKET_PAYLOADS.missingBaseRevision)).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
it("throws on empty event string", () => {
|
it("throws on empty event string", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
isValidSocketMessage({ event: "", payload: {} })
|
isValidSocketMessage({ event: "", payload: {} })
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
* tests/unit/foundry/FoundryAdapter.test.js
|
* tests/unit/foundry/FoundryAdapter.test.js
|
||||||
*
|
*
|
||||||
* Story 1.2 — WebRTC spike: FoundryAdapter probe tests.
|
* 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)
|
* OQ-1 spike result: css-fallback (FoundryVTT v14, 2026-05-21)
|
||||||
* track.enabled = false does NOT stop inbound WebRTC bandwidth.
|
* track.enabled = false does NOT stop inbound WebRTC bandwidth.
|
||||||
@@ -11,7 +12,18 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { FoundryAdapter } from '../../../src/foundry/FoundryAdapter.js';
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -184,12 +196,26 @@ describe('FoundryAdapter.buildWebRTCSurface', () => {
|
|||||||
// ─── Constructor + interface shape parity ─────────────────────────────────────
|
// ─── Constructor + interface shape parity ─────────────────────────────────────
|
||||||
|
|
||||||
describe('FoundryAdapter constructor', () => {
|
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();
|
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)', () => {
|
it('has webrtc = null by default (css-fallback path)', () => {
|
||||||
const adapter = new FoundryAdapter();
|
const adapter = new FoundryAdapter(GAME_STUB);
|
||||||
expect(adapter.webrtc).toBeNull();
|
expect(adapter.webrtc).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,26 +225,154 @@ describe('FoundryAdapter constructor', () => {
|
|||||||
expect(typeof FoundryAdapter.SETTING_WEBRTC_MODE).toBe('string');
|
expect(typeof FoundryAdapter.SETTING_WEBRTC_MODE).toBe('string');
|
||||||
expect(FoundryAdapter.SETTING_WEBRTC_MODE).toBe('webrtcMode');
|
expect(FoundryAdapter.SETTING_WEBRTC_MODE).toBe('webrtcMode');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Interface shape parity with canonical mock', () => {
|
// ─── Surface delegation ────────────────────────────────────────────────────────
|
||||||
it('canonical mock webrtc default matches FoundryAdapter default (null)', () => {
|
|
||||||
const mock = createFoundryAdapterMock();
|
describe('FoundryAdapter surface delegation', () => {
|
||||||
const adapter = new FoundryAdapter();
|
let adapter;
|
||||||
expect(mock.webrtc).toBe(adapter.webrtc); // both null
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('Hooks', HOOKS_STUB);
|
||||||
|
vi.stubGlobal('ui', UI_STUB);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
adapter = new FoundryAdapter(GAME_STUB);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('canonical mock can override webrtc to simulate track-disable surface', () => {
|
describe('settings surface', () => {
|
||||||
const mockSurface = { disableTrack: vi.fn(), enableTrack: vi.fn() };
|
it('settings.register delegates to game.settings.register with namespace', () => {
|
||||||
const mock = createFoundryAdapterMock({ webrtc: mockSurface });
|
adapter.settings.register('visibilityMatrix', { default: {} });
|
||||||
expect(mock.webrtc).toBe(mockSurface);
|
expect(SETTINGS_STUB.register).toHaveBeenCalledWith('scrying-pool', 'visibilityMatrix', { default: {} });
|
||||||
expect(typeof mock.webrtc.disableTrack).toBe('function');
|
|
||||||
expect(typeof mock.webrtc.enableTrack).toBe('function');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buildWebRTCSurface returns object with disableTrack and enableTrack', () => {
|
it('settings.get delegates to game.settings.get with namespace', () => {
|
||||||
const surface = FoundryAdapter.buildWebRTCSurface(makeGameWebrtc());
|
SETTINGS_STUB.get.mockReturnValue({ userId1: 'active' });
|
||||||
expect(typeof surface.disableTrack).toBe('function');
|
const result = adapter.settings.get('visibilityMatrix');
|
||||||
expect(typeof surface.enableTrack).toBe('function');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user