Files
scrying-pool/_bmad-output/implementation-artifacts/1-3-data-layer-foundryadapter-statestore-and-socket-infrastructure.md
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

38 KiB
Raw Permalink Blame History

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

  • Task 1 — Create src/utils/uuid.jsgenerateOpId() utility for PendingOp opId generation
  • Task 2 — Update src/contracts/socket-message.js — add baseRevision to SocketIntentPayload typedef + createSocketIntentMessage() + validator
  • Task 3 — Complete src/foundry/FoundryAdapter.js — update constructor to accept game arg; 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 in init hook; call StateStore.init() in ready hook
  • Task 7 — Create tests/fixtures/foundry-adapter.js — minimal frozen GAME_STUB for 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 pass GAME_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.jsVISIBILITY_STATES, createVisibilityMatrix(), isValidVisibilityMatrix()
  • src/contracts/socket-message.jsSOCKET_EVENTS, MAX_PAYLOAD_BYTES, createSocketIntentMessage(), createSocketEchoMessage(), isValidSocketMessage()
  • src/contracts/pending-op.jscreatePendingOp(), isValidPendingOp()

Mock (established from Story 1.1, updated in Story 1.2):

  • tests/helpers/foundryAdapterMock.jscreateFoundryAdapterMock(overrides) — all 7 surfaces including webrtc: null

Existing fixtures:

  • tests/fixtures/socket-payloads.jsSOCKET_PAYLOADS (validIntent, validEcho, malformed variants)
  • tests/fixtures/state-store-snapshots.jsSTATE_STORE_SNAPSHOTS (empty, threeParticipants, allStates)
  • tests/fixtures/pending-op.jsPENDING_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 unchangedprobeCapability, 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 directlyHooks 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 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

// @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.

/**
 * 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):

  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

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):

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):

// ✅
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.jscatch 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.jsgenerateOpId() using crypto.randomUUID() with time-random fallback
  • Task 2: src/contracts/socket-message.jsbaseRevision 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.jsvisibilityMatrix + showGMSelfFeed settings registered; adapter/stateStore/socketHandler constructed; stateStore.init() called in ready hook; WebRTC probe and fallback
  • Task 7: tests/fixtures/foundry-adapter.jsGAME_STUB, HOOKS_STUB, UI_STUB + individual stubs exported
  • Task 8: tests/fixtures/socket-payloads.jsbaseRevision: 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