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>
38 KiB
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
baseRevisionfield) - 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
- Task 1 — Create
src/utils/uuid.js—generateOpId()utility for PendingOp opId generation - Task 2 — Update
src/contracts/socket-message.js— addbaseRevisiontoSocketIntentPayloadtypedef +createSocketIntentMessage()+ validator - Task 3 — Complete
src/foundry/FoundryAdapter.js— update constructor to acceptgamearg; add all 6 surfaces (settings, socket, users, scenes, notifications, hooks) - Task 4 — Create
src/core/StateStore.js— full implementation: constructor,init(),setVisibility(),getState(),getMatrix(),setMatrix() - Task 5 — Create
src/core/SocketHandler.js— full implementation: constructor,emit(),setReady(),registerPendingOp(),confirmPendingOp(), timeout/retry/revert - Task 6 — Update
module.js— register remaining world settings, construct FoundryAdapter + StateStore + SocketHandler ininithook; callStateStore.init()inreadyhook - Task 7 — Create
tests/fixtures/foundry-adapter.js— minimal frozenGAME_STUBfor FoundryAdapter construction in tests - Task 8 — Expand
tests/fixtures/socket-payloads.js— add missing canonical fixtures per AC-9 - Task 9 — Update
tests/unit/foundry/FoundryAdapter.test.js— add surface delegation tests (settings/socket/users/scenes/notifications/hooks); update constructor tests to passGAME_STUB - Task 10 — Create
tests/unit/core/StateStore.test.js— full unit test coverage - Task 11 — Create
tests/unit/core/SocketHandler.test.js— full unit test coverage including fake timers for timeout paths - 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):
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):
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 includingwebrtc: 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.
/**
* 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:
/**
* @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():
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:
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:
/**
* @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.
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.
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 viahandler.onRevert(op)+ warnconfirmPendingOp(opId)with unknown opId → silently discards (stale ACK)emit()with payload ≥ 4096 bytes → throws (not silently discards)
Task 6: Update module.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:
- The
webrtcModeregistration in Story 1.2 already exists — do not add it again. Only addvisibilityMatrixandshowGMSelfFeed. adapter,stateStore,socketHandlerare module-levelletvariables (notconst) — they're assigned ininit.socketHandler.setReady()is NOT called in Story 1.3'sreadyhook — that requires VisibilityManager which is Story 1.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.
/**
* 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:
// ── 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:
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):
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:
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:
beforeEach(() => {
global.Hooks = { on: vi.fn(), once: vi.fn(), off: vi.fn() };
});
Task 10: tests/unit/core/StateStore.test.js
Test file structure:
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):
constructor: empty matrix, no settings calls in constructorinit()withsettings.getreturning null → matrix stays empty, no throwinit()with valid snapshot → matrix hydrated correctlyinit()with corrupt object → logs warn, matrix stays empty, no throw bubblesgetState(userId)for known user → returns stategetState(userId)for unknown user → returnsnull(not undefined)setVisibility(userId, state)→ updates in-memory statesetVisibility(userId, state)→ callssettings.set(VISIBILITY_MATRIX_KEY, { _version: 1, matrix: {...} })setVisibility(userId, state)→ firesHooks.callAll('scrying-pool:stateChanged', { userId, state, previousState, ... })setVisibility('', state)→ guard: no-op (empty userId)setVisibility(userId, 'invisible')→ guard: no-op (invalid state not in VISIBILITY_STATES)getMatrix()→ returns a copy (mutations to returned object do not affect store)setMatrix(snapshot)→ replaces matrix, calls settings.set, fires hooksetMatrix(invalidMatrix)→ throws TypeError (src/core throws per error-handling rule)
Task 11: tests/unit/core/SocketHandler.test.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):
constructor:_messageQueue = [],_isReady = false,_pendingOps.size === 0emit()beforesetReady()→ pushes to queue, does NOT callsocket.emitemit()aftersetReady()→ callssocket.emit(event, payload)immediatelysetReady()→ drains queue: callssocket.emitfor each queued messagesetReady()→ queue is empty after drainemit()with payload ≥ 4096 bytes → throwsErroremit()with payload of 4095 bytes → does NOT throwconfirmPendingOp(opId)for registered op → clears timeout, removes from mapconfirmPendingOp('unknown-id')→ silently discards, no errorregisterPendingOp()+ advance time 3001ms → retries once (socket.emit called again)- After retry → advance another 3001ms → calls
handler.onRevert(op)+ does NOT throw - After retry + second timeout: warns with
'[ScryingPool]'prefix 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)- Null
handlerat second timeout → warns and returns without crashing
Fake timer pattern (required for timeout tests):
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, neverexport default - vitest globals: false — always import
describe,it,expect,vi,beforeEachfrom'vitest' - Import aliases:
@src,@contracts,@utils,@testsavailable but prefer relative paths in source, aliases in tests if needed
Critical Patterns to Follow
Constructor side-effect-free rule (hard):
// ✅
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):
// ✅ 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):
return this._matrix[userId] ?? null; // ✅
return this._matrix[userId]; // ❌ (may return undefined)
Guard clauses, early return:
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.mdlines 303–355 - Story 1.4 preview (context only):
_bmad-output/planning-artifacts/epics.mdlines 357–395 - FoundryAdapter surface contract:
_bmad-output/planning-artifacts/architecture.md~line 292 - Initialisation order:
_bmad-output/planning-artifacts/architecture.md~line 303 - Import boundary rule:
_bmad-output/planning-artifacts/architecture.mdline 428 - StateStore sole-writer rule:
_bmad-output/planning-artifacts/architecture.mdline 481 - Constructor side-effect-free:
_bmad-output/planning-artifacts/architecture.mdline 487 - Error handling by layer:
_bmad-output/planning-artifacts/architecture.mdline 509 - Socket event names:
_bmad-output/planning-artifacts/architecture.mdline 384 - Naming patterns:
_bmad-output/planning-artifacts/architecture.mdline 360 - Test patterns:
_bmad-output/planning-artifacts/architecture.mdline 527 - OQ-1 spike outcome:
src/foundry/FoundryAdapter.jstop-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 (
baseRevisionrejected as unknown key before validator update) — TDD red/green cycle validated. - Task 12: Pre-existing
scripts/package.mjslint errors (7) andpending-op.jsduplicate typedef (2) fixed. No new errors introduced.
Completion Notes List
- ✅ Task 1:
src/utils/uuid.js—generateOpId()usingcrypto.randomUUID()with time-random fallback - ✅ Task 2:
src/contracts/socket-message.js—baseRevisionadded to typedef, factory, and validator; 17 contract tests pass - ✅ Task 3:
src/foundry/FoundryAdapter.js— constructor(game), 6 surfaces;gtyped asanyfor TS compat; 38 adapter tests pass - ✅ Task 4:
src/core/StateStore.js— full implementation;Hooks.callAllused 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+showGMSelfFeedsettings 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: 0invalidIntent;staleEcho,timeoutIntent,hydrationPayload,missingBaseRevisionadded - ✅ 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.mjswarnings only) - Also fixed: pre-existing
src/contracts/pending-op.jsduplicate@typedef {Object} PendingOp(TS2300),src/types/foundry-globals.d.tsextended withuiglobal 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 — addeduideclaration)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