Fix Story 1.3: StateStore spec compliance and minor cleanup

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

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

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