Files
scrying-pool/_bmad-output/implementation-artifacts/1-3-data-layer-foundryadapter-statestore-and-socket-infrastructure.md
T
uberwald 5ba7717ecd 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>
2026-05-22 11:38:45 +02:00

969 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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