Fix Story 1.3: StateStore spec compliance and minor cleanup
Critical Fix: - StateStore now uses global Hooks.callAll directly (per spec) - Removed hooks parameter from StateStore constructor - Updated module.js to pass only adapter.settings - Updated tests to stub globalThis.Hooks Minor Cleanup: - Fixed misleading warning in SocketHandler.registerPendingOp - Added clarifying comment for setMatrix _revision behavior Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
+968
@@ -0,0 +1,968 @@
|
||||
# Story 1.3: Data Layer — FoundryAdapter, StateStore & Socket Infrastructure
|
||||
|
||||
## Status: review
|
||||
|
||||
## Story
|
||||
|
||||
As a **GM**,
|
||||
I want camera visibility changes to persist and broadcast to all connected clients reliably,
|
||||
So that every participant's Foundry client always shows the correct camera state, even after page refreshes or mid-session joins.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1 — Init hook wiring:**
|
||||
**Given** the module initialises
|
||||
**When** `Hooks.once('init')` fires
|
||||
**Then** world settings (`scrying-pool.visibilityMatrix`, `scrying-pool.webrtcMode`, `scrying-pool.showGMSelfFeed`) are all registered
|
||||
**And** `FoundryAdapter` is constructed and injected into `StateStore` and `SocketHandler`
|
||||
|
||||
**AC-2 — setVisibility persistence:**
|
||||
**Given** `StateStore.setVisibility(participantId, targetState)` is called
|
||||
**When** the call completes
|
||||
**Then** the in-memory `visibilityMatrix` is updated immediately
|
||||
**And** `adapter.settings.set('visibilityMatrix', { _version: 1, matrix: {...} })` is called to persist
|
||||
|
||||
**AC-3 — Socket emit (500ms):**
|
||||
**Given** `SocketHandler.emit(event, payload)` is called while `isReady = true`
|
||||
**When** the message is sent
|
||||
**Then** all connected clients receive the authoritative echo within 500ms on a local network
|
||||
|
||||
**AC-4 — PendingOp confirmation:**
|
||||
**Given** a client receives a socket echo message
|
||||
**When** the `opId` matches a registered `PendingOp`
|
||||
**Then** the `PendingOp` is cleared and state is confirmed (timer cancelled)
|
||||
**And** if the echo `opId` does NOT match any pending op it is discarded as stale (no error)
|
||||
|
||||
**AC-5 — Timeout, retry, revert:**
|
||||
**Given** a socket emit is unacknowledged for 3 seconds
|
||||
**When** the timeout fires
|
||||
**Then** the module retries exactly once
|
||||
**And** if still unacknowledged after another 3 seconds: logs `console.warn('[ScryingPool]', ...)` and calls `handler.onRevert(pendingOp)`
|
||||
**And** the `handler` is provided at `setReady(handler)` time — `null` handler → warn and return
|
||||
|
||||
**AC-6 — Mid-session join hydration:**
|
||||
**Given** a new client joins mid-session
|
||||
**When** `Hooks.once('ready')` fires for them
|
||||
**Then** `StateStore.init()` is called and hydrates the in-memory matrix from `scrying-pool.visibilityMatrix` world setting
|
||||
|
||||
**AC-7 — Page refresh restore:**
|
||||
**Given** the page refreshes
|
||||
**When** the module re-initialises
|
||||
**Then** all participant states are restored from the persisted world setting via `StateStore.init()`
|
||||
|
||||
**AC-8 — Null webrtc safe:**
|
||||
**Given** `game.webrtc` is null (AV disabled or not yet initialised)
|
||||
**When** the module initialises
|
||||
**Then** `FoundryAdapter.webrtc` is `null`, no errors are thrown, no code attempts webrtc access
|
||||
|
||||
**AC-9 — Fixture coverage:**
|
||||
**Given** the test suite
|
||||
**Then** `tests/fixtures/socket-payloads.js` defines canonical frozen fixtures for:
|
||||
- Valid intent payload (with `baseRevision` field)
|
||||
- Valid authoritative echo/ack payload
|
||||
- Stale ACK (opId not in pendingOps)
|
||||
- Timeout + retry + revert sequence payloads
|
||||
- Hydrated setting payload (`{ _version: 1, matrix: {...} }`)
|
||||
- Invalid/malformed payload (fails validator)
|
||||
|
||||
**AC-10 — Zero game.* in core:**
|
||||
**Then** `StateStore`, `SocketHandler` have zero direct `game.*` access in their source files (verified by import boundary rule + test isolation)
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] **Task 1** — Create `src/utils/uuid.js` — `generateOpId()` utility for PendingOp opId generation
|
||||
- [x] **Task 2** — Update `src/contracts/socket-message.js` — add `baseRevision` to `SocketIntentPayload` typedef + `createSocketIntentMessage()` + validator
|
||||
- [x] **Task 3** — Complete `src/foundry/FoundryAdapter.js` — update constructor to accept `game` arg; add all 6 surfaces (settings, socket, users, scenes, notifications, hooks)
|
||||
- [x] **Task 4** — Create `src/core/StateStore.js` — full implementation: constructor, `init()`, `setVisibility()`, `getState()`, `getMatrix()`, `setMatrix()`
|
||||
- [x] **Task 5** — Create `src/core/SocketHandler.js` — full implementation: constructor, `emit()`, `setReady()`, `registerPendingOp()`, `confirmPendingOp()`, timeout/retry/revert
|
||||
- [x] **Task 6** — Update `module.js` — register remaining world settings, construct FoundryAdapter + StateStore + SocketHandler in `init` hook; call `StateStore.init()` in `ready` hook
|
||||
- [x] **Task 7** — Create `tests/fixtures/foundry-adapter.js` — minimal frozen `GAME_STUB` for FoundryAdapter construction in tests
|
||||
- [x] **Task 8** — Expand `tests/fixtures/socket-payloads.js` — add missing canonical fixtures per AC-9
|
||||
- [x] **Task 9** — Update `tests/unit/foundry/FoundryAdapter.test.js` — add surface delegation tests (settings/socket/users/scenes/notifications/hooks); update constructor tests to pass `GAME_STUB`
|
||||
- [x] **Task 10** — Create `tests/unit/core/StateStore.test.js` — full unit test coverage
|
||||
- [x] **Task 11** — Create `tests/unit/core/SocketHandler.test.js` — full unit test coverage including fake timers for timeout paths
|
||||
- [x] **Task 12** — Verify pipeline: `npm run lint && npm run typecheck && npm run test` — all pass, zero regressions
|
||||
|
||||
---
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Context: What Exists Before This Story
|
||||
|
||||
**`src/foundry/FoundryAdapter.js`** — ALREADY EXISTS (Story 1.2 skeleton):
|
||||
```js
|
||||
export class FoundryAdapter {
|
||||
static SETTINGS_NS = 'scrying-pool';
|
||||
static SETTING_WEBRTC_MODE = 'webrtcMode';
|
||||
constructor() {
|
||||
this.webrtc = null; // set externally at ready time
|
||||
}
|
||||
static probeCapability(gameWebrtc) { ... } // returns 'unsupported'|'css-fallback'
|
||||
static buildWebRTCSurface(gameWebrtc) { ... } // forward compat only — unreachable in v14
|
||||
}
|
||||
```
|
||||
Story 1.3 adds `constructor(game)` + all 6 surfaces. **Do not remove probeCapability or buildWebRTCSurface — they are tested documentation.**
|
||||
|
||||
**`module.js`** — ALREADY EXISTS (Story 1.2):
|
||||
```js
|
||||
Hooks.once("init", () => {
|
||||
game.settings.register("scrying-pool", "webrtcMode", { ... });
|
||||
// Story 1.3+: register remaining settings, construct classes
|
||||
});
|
||||
Hooks.once("ready", () => {
|
||||
// Story 1.3+: hydrate StateStore
|
||||
});
|
||||
```
|
||||
|
||||
**Contracts (ALL complete from Story 1.1):**
|
||||
- `src/contracts/visibility-matrix.js` — `VISIBILITY_STATES`, `createVisibilityMatrix()`, `isValidVisibilityMatrix()`
|
||||
- `src/contracts/socket-message.js` — `SOCKET_EVENTS`, `MAX_PAYLOAD_BYTES`, `createSocketIntentMessage()`, `createSocketEchoMessage()`, `isValidSocketMessage()`
|
||||
- `src/contracts/pending-op.js` — `createPendingOp()`, `isValidPendingOp()`
|
||||
|
||||
**Mock (established from Story 1.1, updated in Story 1.2):**
|
||||
- `tests/helpers/foundryAdapterMock.js` — `createFoundryAdapterMock(overrides)` — all 7 surfaces including `webrtc: null`
|
||||
|
||||
**Existing fixtures:**
|
||||
- `tests/fixtures/socket-payloads.js` — `SOCKET_PAYLOADS` (validIntent, validEcho, malformed variants)
|
||||
- `tests/fixtures/state-store-snapshots.js` — `STATE_STORE_SNAPSHOTS` (empty, threeParticipants, allStates)
|
||||
- `tests/fixtures/pending-op.js` — `PENDING_OP_FIXTURES` (valid, timeoutNull, expiredIssuedAt, emptyOpId)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `src/utils/uuid.js`
|
||||
|
||||
Simple opId generator. No external deps.
|
||||
|
||||
```js
|
||||
/**
|
||||
* Generates a unique operation ID for PendingOp tracking.
|
||||
* Uses crypto.randomUUID() with a short-ID fallback.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateOpId() {
|
||||
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `op-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
```
|
||||
|
||||
**Import rule**: `src/utils/` has no internal imports — no other src/ imports allowed.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update `src/contracts/socket-message.js`
|
||||
|
||||
Add `baseRevision` to `SocketIntentPayload`. This is required for the latest-revision-wins guard in Story 1.4.
|
||||
|
||||
**Change `SocketIntentPayload` typedef:**
|
||||
```js
|
||||
/**
|
||||
* @typedef {Object} SocketIntentPayload
|
||||
* @property {string} opId - Unique operation ID (non-empty string).
|
||||
* @property {string} userId - Target participant userId (non-empty string).
|
||||
* @property {string} targetState - Desired VisibilityState.
|
||||
* @property {number} baseRevision - Revision counter at the time the GM issued the intent.
|
||||
*/
|
||||
```
|
||||
|
||||
**Change `createSocketIntentMessage()`:**
|
||||
```js
|
||||
export function createSocketIntentMessage(opId, userId, targetState, baseRevision) {
|
||||
return {
|
||||
event: SOCKET_EVENTS.VISIBILITY_SET,
|
||||
payload: { opId, userId, targetState, baseRevision },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Change `isValidSocketMessage()` — intent branch:**
|
||||
Add validation for `baseRevision`:
|
||||
```js
|
||||
if (typeof baseRevision !== 'number' || !Number.isFinite(baseRevision) || baseRevision < 0) {
|
||||
throw new TypeError('SocketMessage: baseRevision must be a finite non-negative number');
|
||||
}
|
||||
```
|
||||
Also add `baseRevision` to the destructure and `payloadRest` check.
|
||||
|
||||
**⚠️ Update existing socket-message.test.js** — the existing `validIntent` fixture may need `baseRevision` added. Check the test file and update accordingly. Do NOT remove existing tests — extend them.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Complete `src/foundry/FoundryAdapter.js`
|
||||
|
||||
**Constructor signature change**: `constructor()` → `constructor(game)`.
|
||||
|
||||
Full implementation:
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {object} game - The FoundryVTT `game` global object.
|
||||
*/
|
||||
constructor(game) {
|
||||
this._game = game;
|
||||
/** @type {{ disableTrack(userId: string): void, enableTrack(userId: string): void } | null} */
|
||||
this.webrtc = null; // set externally at ready time via probeCapability
|
||||
|
||||
const ns = FoundryAdapter.SETTINGS_NS;
|
||||
|
||||
/** settings surface — wraps game.settings, pre-namespaced to 'scrying-pool' */
|
||||
this.settings = {
|
||||
/** @param {string} key @param {object} config */
|
||||
register: (key, config) => game.settings.register(ns, key, config),
|
||||
/** @param {string} key @returns {unknown} */
|
||||
get: (key) => game.settings.get(ns, key),
|
||||
/** @param {string} key @param {unknown} value @returns {Promise<unknown>} */
|
||||
set: (key, value) => game.settings.set(ns, key, value),
|
||||
};
|
||||
|
||||
/** socket surface — wraps game.socket */
|
||||
this.socket = {
|
||||
emit: (event, payload) => game.socket.emit(event, payload),
|
||||
on: (event, handler) => game.socket.on(event, handler),
|
||||
off: (event, handler) => game.socket.off(event, handler),
|
||||
};
|
||||
|
||||
/** users surface */
|
||||
this.users = {
|
||||
get: (userId) => game.users?.get(userId) ?? null,
|
||||
all: () => Array.from(game.users ?? []),
|
||||
isGM: (userId) => userId
|
||||
? (game.users?.get(userId)?.isGM ?? false)
|
||||
: (game.user?.isGM ?? false),
|
||||
current: () => game.user ?? null,
|
||||
};
|
||||
|
||||
/** scenes surface — game.scenes.active is the current scene */
|
||||
this.scenes = {
|
||||
current: () => game.scenes?.active ?? null,
|
||||
get: (id) => game.scenes?.get(id) ?? null,
|
||||
};
|
||||
|
||||
/**
|
||||
* notifications surface — wraps ui.notifications (available post-ready).
|
||||
* Catches silently — notification failures must never crash visibility logic.
|
||||
*/
|
||||
this.notifications = {
|
||||
info: (msg) => { try { ui.notifications?.info(msg); } catch (err) { console.warn('[ScryingPool] notifications.info:', err); } },
|
||||
warn: (msg) => { try { ui.notifications?.warn(msg); } catch (err) { console.warn('[ScryingPool] notifications.warn:', err); } },
|
||||
error: (msg) => { try { ui.notifications?.error(msg); } catch (err) { console.warn('[ScryingPool] notifications.error:', err); } },
|
||||
};
|
||||
|
||||
/** hooks surface — wraps FoundryVTT Hooks global */
|
||||
this.hooks = {
|
||||
on: (event, handler) => Hooks.on(event, handler),
|
||||
once: (event, handler) => Hooks.once(event, handler),
|
||||
off: (event, handler) => Hooks.off(event, handler),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Keep static methods unchanged** — `probeCapability`, `buildWebRTCSurface`, `SETTINGS_NS`, `SETTING_WEBRTC_MODE` are unchanged from Story 1.2.
|
||||
|
||||
**Settings key note**: `settings.set('visibilityMatrix', value)` — the key passed is the SHORT key without namespace. The adapter's `settings.set` implementation calls `game.settings.set('scrying-pool', 'visibilityMatrix', value)`. Never call `settings.set('scrying-pool.visibilityMatrix', value)` — the namespace is added by the adapter.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `src/core/StateStore.js`
|
||||
|
||||
**Import rule**: may only import from `src/contracts/` and `src/utils/`.
|
||||
**Calls `Hooks.callAll` directly** — `Hooks` is a FoundryVTT standalone global (NOT `game.*`), so this is allowed in src/core/. Tests stub `global.Hooks` via vitest.
|
||||
|
||||
```js
|
||||
import { VISIBILITY_STATES, isValidVisibilityMatrix } from '../contracts/visibility-matrix.js';
|
||||
|
||||
export const VISIBILITY_MATRIX_KEY = 'visibilityMatrix';
|
||||
|
||||
export class StateStore {
|
||||
/**
|
||||
* @param {{ get(key: string): unknown, set(key: string, value: unknown): Promise<unknown> }} settings
|
||||
* Injected from adapter.settings (already namespaced).
|
||||
*/
|
||||
constructor(settings) {
|
||||
this._settings = settings;
|
||||
this._matrix = {}; // userId → VisibilityState
|
||||
this._version = 1;
|
||||
this._revision = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates the in-memory matrix from the persisted world setting.
|
||||
* Called from module.js Hooks.once('ready').
|
||||
* Safe to call multiple times (idempotent — last call wins).
|
||||
*/
|
||||
init() {
|
||||
try {
|
||||
const raw = this._settings.get(VISIBILITY_MATRIX_KEY);
|
||||
if (raw && typeof raw === 'object') {
|
||||
const validated = isValidVisibilityMatrix(raw);
|
||||
this._matrix = { ...validated.matrix };
|
||||
this._version = validated._version ?? 1;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Corrupt/missing world setting — start fresh (no throw)
|
||||
console.warn('[ScryingPool] StateStore.init: could not hydrate matrix, starting fresh');
|
||||
this._matrix = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current VisibilityState for a participant, or null if unknown.
|
||||
* @param {string} userId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getState(userId) {
|
||||
return this._matrix[userId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deep snapshot of the current visibility matrix.
|
||||
* Callers receive a copy — mutations do not affect internal state.
|
||||
* @returns {{ _version: number, matrix: Record<string, string> }}
|
||||
*/
|
||||
getMatrix() {
|
||||
return { _version: this._version, matrix: { ...this._matrix } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single participant's visibility state, persists, and emits hook.
|
||||
* Guard: no-op if userId empty or targetState not a valid VISIBILITY_STATE.
|
||||
* @param {string} userId
|
||||
* @param {string} targetState - Must be a value in VISIBILITY_STATES.
|
||||
*/
|
||||
setVisibility(userId, targetState) {
|
||||
if (!userId || typeof userId !== 'string') return;
|
||||
if (!VISIBILITY_STATES.includes(targetState)) return;
|
||||
|
||||
const previousState = this._matrix[userId] ?? null;
|
||||
this._matrix[userId] = targetState;
|
||||
this._revision++;
|
||||
|
||||
this._settings.set(VISIBILITY_MATRIX_KEY, { _version: this._version, matrix: { ...this._matrix } });
|
||||
Hooks.callAll('scrying-pool:stateChanged', {
|
||||
userId,
|
||||
state: targetState,
|
||||
previousState,
|
||||
timestamp: Date.now(),
|
||||
revision: this._revision,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the entire matrix (used by preset apply and scene restore).
|
||||
* Validates input — throws TypeError on invalid matrix (src/core throws per error-handling rule).
|
||||
* @param {{ _version: number, matrix: Record<string, string> }} matrix
|
||||
*/
|
||||
setMatrix(matrix) {
|
||||
const validated = isValidVisibilityMatrix(matrix);
|
||||
this._matrix = { ...validated.matrix };
|
||||
this._version = validated._version ?? 1;
|
||||
this._revision++;
|
||||
|
||||
this._settings.set(VISIBILITY_MATRIX_KEY, { _version: this._version, matrix: { ...this._matrix } });
|
||||
Hooks.callAll('scrying-pool:stateChanged', {
|
||||
matrix: this.getMatrix(),
|
||||
timestamp: Date.now(),
|
||||
revision: this._revision,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `src/core/SocketHandler.js`
|
||||
|
||||
**Import rule**: may only import from `src/contracts/` and `src/utils/`.
|
||||
**No game.* access** — takes `socket` and `hooks` surfaces via constructor injection.
|
||||
|
||||
```js
|
||||
import { MAX_PAYLOAD_BYTES } from '../contracts/socket-message.js';
|
||||
|
||||
export const SOCKET_TIMEOUT_MS = 3000;
|
||||
|
||||
export class SocketHandler {
|
||||
/**
|
||||
* @param {{ emit(event: string, payload: object): void, on(event: string, handler: Function): void, off(event: string, handler: Function): void }} socket
|
||||
* @param {{ on(event: string, handler: Function): void, once(event: string, handler: Function): void }} hooks
|
||||
*/
|
||||
constructor(socket, hooks) {
|
||||
this._socket = socket;
|
||||
this._hooks = hooks;
|
||||
/** @type {Array<{event: string, payload: object}>} */
|
||||
this._messageQueue = [];
|
||||
this._isReady = false;
|
||||
/** @type {Map<string, import('../contracts/pending-op.js').PendingOp>} */
|
||||
this._pendingOps = new Map();
|
||||
/** @type {{ onRevert(pendingOp: object): void } | null} */
|
||||
this._handler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the handler as ready, stores the revert callback handler, and drains the queue.
|
||||
* Called from module.js at Hooks.once('ready') AFTER VisibilityManager is constructed.
|
||||
* In Story 1.3, module.js does NOT call setReady yet — that is Story 1.4's job.
|
||||
* @param {{ onRevert(pendingOp: object): void } | null} handler
|
||||
*/
|
||||
setReady(handler) {
|
||||
this._isReady = true;
|
||||
this._handler = handler;
|
||||
this._drainQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an event on the socket. If not yet ready, queues the message.
|
||||
* Throws if payload exceeds MAX_PAYLOAD_BYTES (4096).
|
||||
* @param {string} event
|
||||
* @param {object} payload
|
||||
*/
|
||||
emit(event, payload) {
|
||||
const encoded = JSON.stringify(payload);
|
||||
if (encoded.length >= MAX_PAYLOAD_BYTES) {
|
||||
throw new Error(`[ScryingPool] SocketHandler: payload exceeds ${MAX_PAYLOAD_BYTES} bytes (${encoded.length})`);
|
||||
}
|
||||
if (!this._isReady) {
|
||||
this._messageQueue.push({ event, payload });
|
||||
return;
|
||||
}
|
||||
this._socket.emit(event, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a PendingOp and starts its 3-second timeout.
|
||||
* @param {import('../contracts/pending-op.js').PendingOp} pendingOp
|
||||
*/
|
||||
registerPendingOp(pendingOp) {
|
||||
const timeoutId = setTimeout(() => this._onTimeout(pendingOp.opId, false), SOCKET_TIMEOUT_MS);
|
||||
pendingOp.timeoutId = timeoutId;
|
||||
this._pendingOps.set(pendingOp.opId, pendingOp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a PendingOp by opId — clears timer and removes from map.
|
||||
* Stale/unknown opId: discards silently.
|
||||
* @param {string} opId
|
||||
*/
|
||||
confirmPendingOp(opId) {
|
||||
const op = this._pendingOps.get(opId);
|
||||
if (!op) return; // stale echo — discard
|
||||
clearTimeout(op.timeoutId);
|
||||
this._pendingOps.delete(opId);
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_drainQueue() {
|
||||
for (const { event, payload } of this._messageQueue) {
|
||||
this._socket.emit(event, payload);
|
||||
}
|
||||
this._messageQueue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} opId
|
||||
* @param {boolean} isRetry - true if this is the second timeout (after one retry)
|
||||
*/
|
||||
_onTimeout(opId, isRetry) {
|
||||
const op = this._pendingOps.get(opId);
|
||||
if (!op) return; // already confirmed — ignore stale timer
|
||||
|
||||
if (!isRetry) {
|
||||
// Retry once: re-emit and set a new timeout
|
||||
if (this._isReady) {
|
||||
try {
|
||||
this._socket.emit('scrying-pool.visibility.set', op);
|
||||
} catch (_err) {
|
||||
// ignore emit error on retry
|
||||
}
|
||||
}
|
||||
const retryTimeoutId = setTimeout(() => this._onTimeout(opId, true), SOCKET_TIMEOUT_MS);
|
||||
op.timeoutId = retryTimeoutId;
|
||||
return;
|
||||
}
|
||||
|
||||
// Second timeout — give up: revert + warn
|
||||
this._pendingOps.delete(opId);
|
||||
console.warn('[ScryingPool] SocketHandler: unacknowledged op after retry, reverting', opId);
|
||||
if (!this._handler) return;
|
||||
this._handler.onRevert(op);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key behaviors**:
|
||||
- `_onTimeout(opId, false)` = first timeout → retry once → schedule `_onTimeout(opId, true)`
|
||||
- `_onTimeout(opId, true)` = second timeout → revert via `handler.onRevert(op)` + warn
|
||||
- `confirmPendingOp(opId)` with unknown opId → silently discards (stale ACK)
|
||||
- `emit()` with payload ≥ 4096 bytes → **throws** (not silently discards)
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update `module.js`
|
||||
|
||||
```js
|
||||
// @ts-nocheck
|
||||
import { FoundryAdapter } from './src/foundry/FoundryAdapter.js';
|
||||
import { StateStore } from './src/core/StateStore.js';
|
||||
import { SocketHandler } from './src/core/SocketHandler.js';
|
||||
|
||||
// Module-level references — set in init, used in ready
|
||||
let adapter;
|
||||
let stateStore;
|
||||
let socketHandler;
|
||||
|
||||
Hooks.once("init", () => {
|
||||
console.log("[ScryingPool] init — module loading");
|
||||
|
||||
// Already registered in Story 1.2 — leave as-is:
|
||||
game.settings.register("scrying-pool", "webrtcMode", { ... });
|
||||
|
||||
// Story 1.3: register remaining world settings
|
||||
game.settings.register("scrying-pool", "visibilityMatrix", {
|
||||
scope: "world",
|
||||
config: false,
|
||||
type: Object,
|
||||
default: { _version: 1, matrix: {} },
|
||||
});
|
||||
|
||||
game.settings.register("scrying-pool", "showGMSelfFeed", {
|
||||
scope: "world",
|
||||
config: true,
|
||||
type: Boolean,
|
||||
default: true,
|
||||
});
|
||||
|
||||
// Construct data layer (side-effect-free constructors)
|
||||
adapter = new FoundryAdapter(game);
|
||||
stateStore = new StateStore(adapter.settings);
|
||||
socketHandler = new SocketHandler(adapter.socket, adapter.hooks);
|
||||
});
|
||||
|
||||
Hooks.once("ready", () => {
|
||||
console.log("[ScryingPool] ready — module active");
|
||||
|
||||
// Hydrate StateStore from world setting (AC-6, AC-7)
|
||||
stateStore.init();
|
||||
|
||||
// Probe WebRTC capability and set adapter.webrtc (AC-8)
|
||||
const outcome = FoundryAdapter.probeCapability(game.webrtc);
|
||||
adapter.webrtc = outcome === 'track-disable'
|
||||
? FoundryAdapter.buildWebRTCSurface(game.webrtc)
|
||||
: null;
|
||||
// Update world setting to reflect actual detected mode
|
||||
adapter.settings.set(FoundryAdapter.SETTING_WEBRTC_MODE, outcome);
|
||||
|
||||
// Story 1.4: construct VisibilityManager and call socketHandler.setReady(visibilityManager)
|
||||
});
|
||||
```
|
||||
|
||||
**Important notes for module.js:**
|
||||
1. The `webrtcMode` registration in Story 1.2 already exists — do not add it again. Only add `visibilityMatrix` and `showGMSelfFeed`.
|
||||
2. `adapter`, `stateStore`, `socketHandler` are module-level `let` variables (not `const`) — they're assigned in `init`.
|
||||
3. `socketHandler.setReady()` is NOT called in Story 1.3's `ready` hook — that requires VisibilityManager which is Story 1.4.
|
||||
4. The `// Already registered in Story 1.2 — leave as-is:` note above is pseudocode — do not change the existing webrtcMode registration block.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: `tests/fixtures/foundry-adapter.js`
|
||||
|
||||
Minimal frozen game stub for constructing real `FoundryAdapter` in tests.
|
||||
|
||||
```js
|
||||
/**
|
||||
* tests/fixtures/foundry-adapter.js
|
||||
*
|
||||
* Minimal frozen game stub for FoundryAdapter construction in tests.
|
||||
* Use this when you need a real FoundryAdapter instance (not the mock).
|
||||
* For mock-based tests, use createFoundryAdapterMock() from helpers/.
|
||||
*/
|
||||
|
||||
export const GAME_STUB = Object.freeze({
|
||||
settings: Object.freeze({
|
||||
register: () => {},
|
||||
get: () => null,
|
||||
set: () => Promise.resolve(),
|
||||
}),
|
||||
socket: Object.freeze({
|
||||
emit: () => {},
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
}),
|
||||
users: Object.freeze({
|
||||
get: () => null,
|
||||
[Symbol.iterator]: function*() {}, // supports Array.from(game.users)
|
||||
}),
|
||||
user: Object.freeze({ isGM: false }),
|
||||
scenes: Object.freeze({
|
||||
active: null,
|
||||
get: () => null,
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Expand `tests/fixtures/socket-payloads.js`
|
||||
|
||||
**Add to the existing `SOCKET_PAYLOADS` export** — do NOT replace the file, append new entries:
|
||||
|
||||
```js
|
||||
// ── Valid intent with baseRevision ────────────────────────────────────────
|
||||
validIntentWithRevision: Object.freeze({
|
||||
event: "scrying-pool.visibility.set",
|
||||
payload: Object.freeze({
|
||||
opId: "op-010",
|
||||
userId: "user-abc",
|
||||
targetState: "hidden",
|
||||
baseRevision: 0,
|
||||
}),
|
||||
}),
|
||||
|
||||
// ── Stale ACK — opId not in pendingOps ───────────────────────────────────
|
||||
staleEcho: Object.freeze({
|
||||
event: "scrying-pool.visibility.updated",
|
||||
payload: Object.freeze({
|
||||
opId: "op-stale-999", // not registered in any pendingOps map
|
||||
userId: "user-abc",
|
||||
state: "hidden",
|
||||
revision: 5,
|
||||
}),
|
||||
}),
|
||||
|
||||
// ── Timeout+retry sequence: intent that will time out ────────────────────
|
||||
timeoutIntent: Object.freeze({
|
||||
event: "scrying-pool.visibility.set",
|
||||
payload: Object.freeze({
|
||||
opId: "op-timeout-001",
|
||||
userId: "user-abc",
|
||||
targetState: "hidden",
|
||||
baseRevision: 0,
|
||||
}),
|
||||
}),
|
||||
|
||||
// ── Hydration setting payload ─────────────────────────────────────────────
|
||||
hydrationPayload: Object.freeze({
|
||||
_version: 1,
|
||||
matrix: Object.freeze({
|
||||
"user-001": "active",
|
||||
"user-002": "hidden",
|
||||
}),
|
||||
}),
|
||||
```
|
||||
|
||||
**Also update the existing `validIntent`** to include `baseRevision: 0` to match the updated contract. This is a breaking fixture change — update any existing tests that use `validIntent` without `baseRevision`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Update `tests/unit/foundry/FoundryAdapter.test.js`
|
||||
|
||||
The existing 18 tests cover `probeCapability`, `buildWebRTCSurface`, and basic constructor. Story 1.3 adds surface delegation tests and updates constructor tests.
|
||||
|
||||
**Update constructor tests** — the constructor now requires `game`:
|
||||
```js
|
||||
import { GAME_STUB } from '../../fixtures/foundry-adapter.js';
|
||||
|
||||
// Update any `new FoundryAdapter()` calls to `new FoundryAdapter(GAME_STUB)`
|
||||
```
|
||||
|
||||
**Add a new describe block for surface delegation** (~12 new tests):
|
||||
```js
|
||||
describe('settings surface', () => {
|
||||
it('register() delegates to game.settings.register with namespace', () => { ... })
|
||||
it('get() delegates to game.settings.get with namespace', () => { ... })
|
||||
it('set() delegates to game.settings.set with namespace and returns result', () => { ... })
|
||||
})
|
||||
|
||||
describe('socket surface', () => {
|
||||
it('emit() delegates to game.socket.emit', () => { ... })
|
||||
it('on() delegates to game.socket.on', () => { ... })
|
||||
it('off() delegates to game.socket.off', () => { ... })
|
||||
})
|
||||
|
||||
describe('users surface', () => {
|
||||
it('get(userId) returns game.users.get(userId) or null', () => { ... })
|
||||
it('isGM() with no arg checks game.user.isGM', () => { ... })
|
||||
it('isGM(userId) checks game.users.get(userId).isGM', () => { ... })
|
||||
it('current() returns game.user', () => { ... })
|
||||
})
|
||||
|
||||
describe('scenes surface', () => {
|
||||
it('current() returns game.scenes.active or null', () => { ... })
|
||||
})
|
||||
|
||||
describe('hooks surface', () => {
|
||||
it('on() delegates to Hooks.on', () => { ... })
|
||||
it('once() delegates to Hooks.once', () => { ... })
|
||||
})
|
||||
```
|
||||
|
||||
Use `vi.fn()` stubs on the game stub fields. Example:
|
||||
```js
|
||||
const gameMock = {
|
||||
settings: { register: vi.fn(), get: vi.fn(() => 'value'), set: vi.fn(() => Promise.resolve()) },
|
||||
socket: { emit: vi.fn(), on: vi.fn(), off: vi.fn() },
|
||||
users: { get: vi.fn(() => null), [Symbol.iterator]: function*() {} },
|
||||
user: { isGM: true },
|
||||
scenes: { active: null, get: vi.fn(() => null) },
|
||||
};
|
||||
```
|
||||
|
||||
Also stub `global.Hooks` for hooks surface tests:
|
||||
```js
|
||||
beforeEach(() => {
|
||||
global.Hooks = { on: vi.fn(), once: vi.fn(), off: vi.fn() };
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: `tests/unit/core/StateStore.test.js`
|
||||
|
||||
Test file structure:
|
||||
```js
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { StateStore, VISIBILITY_MATRIX_KEY } from '../../../src/core/StateStore.js';
|
||||
import { STATE_STORE_SNAPSHOTS } from '../../fixtures/state-store-snapshots.js';
|
||||
|
||||
describe('StateStore', () => {
|
||||
let settings;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
// Stub Hooks global — StateStore calls Hooks.callAll
|
||||
global.Hooks = { callAll: vi.fn() };
|
||||
|
||||
settings = {
|
||||
get: vi.fn(() => null),
|
||||
set: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
store = new StateStore(settings);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Required test cases (12 minimum):**
|
||||
|
||||
1. `constructor`: empty matrix, no settings calls in constructor
|
||||
2. `init()` with `settings.get` returning null → matrix stays empty, no throw
|
||||
3. `init()` with valid snapshot → matrix hydrated correctly
|
||||
4. `init()` with corrupt object → logs warn, matrix stays empty, no throw bubbles
|
||||
5. `getState(userId)` for known user → returns state
|
||||
6. `getState(userId)` for unknown user → returns `null` (not undefined)
|
||||
7. `setVisibility(userId, state)` → updates in-memory state
|
||||
8. `setVisibility(userId, state)` → calls `settings.set(VISIBILITY_MATRIX_KEY, { _version: 1, matrix: {...} })`
|
||||
9. `setVisibility(userId, state)` → fires `Hooks.callAll('scrying-pool:stateChanged', { userId, state, previousState, ... })`
|
||||
10. `setVisibility('', state)` → guard: no-op (empty userId)
|
||||
11. `setVisibility(userId, 'invisible')` → guard: no-op (invalid state not in VISIBILITY_STATES)
|
||||
12. `getMatrix()` → returns a copy (mutations to returned object do not affect store)
|
||||
13. `setMatrix(snapshot)` → replaces matrix, calls settings.set, fires hook
|
||||
14. `setMatrix(invalidMatrix)` → throws TypeError (src/core throws per error-handling rule)
|
||||
|
||||
---
|
||||
|
||||
### Task 11: `tests/unit/core/SocketHandler.test.js`
|
||||
|
||||
```js
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SocketHandler, SOCKET_TIMEOUT_MS } from '../../../src/core/SocketHandler.js';
|
||||
import { createPendingOp } from '../../../src/contracts/pending-op.js';
|
||||
import { SOCKET_PAYLOADS } from '../../fixtures/socket-payloads.js';
|
||||
```
|
||||
|
||||
**Required test cases (14 minimum):**
|
||||
|
||||
1. `constructor`: `_messageQueue = []`, `_isReady = false`, `_pendingOps.size === 0`
|
||||
2. `emit()` before `setReady()` → pushes to queue, does NOT call `socket.emit`
|
||||
3. `emit()` after `setReady()` → calls `socket.emit(event, payload)` immediately
|
||||
4. `setReady()` → drains queue: calls `socket.emit` for each queued message
|
||||
5. `setReady()` → queue is empty after drain
|
||||
6. `emit()` with payload ≥ 4096 bytes → **throws** `Error`
|
||||
7. `emit()` with payload of 4095 bytes → does NOT throw
|
||||
8. `confirmPendingOp(opId)` for registered op → clears timeout, removes from map
|
||||
9. `confirmPendingOp('unknown-id')` → silently discards, no error
|
||||
10. `registerPendingOp()` + advance time 3001ms → retries once (socket.emit called again)
|
||||
11. After retry → advance another 3001ms → calls `handler.onRevert(op)` + does NOT throw
|
||||
12. After retry + second timeout: warns with `'[ScryingPool]'` prefix
|
||||
13. `confirmPendingOp()` called after first timeout but before retry → timer for retry is still active but op is gone; second timeout fires with no-op (op not in map)
|
||||
14. Null `handler` at second timeout → warns and returns without crashing
|
||||
|
||||
**Fake timer pattern** (required for timeout tests):
|
||||
```js
|
||||
import { vi } from 'vitest';
|
||||
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('retries after 3s timeout', () => {
|
||||
const socketMock = { emit: vi.fn() };
|
||||
const hooksMock = { on: vi.fn(), once: vi.fn() };
|
||||
const handler = { onRevert: vi.fn() };
|
||||
const sh = new SocketHandler(socketMock, hooksMock);
|
||||
sh.setReady(handler);
|
||||
|
||||
const op = createPendingOp('op-1', 'user-abc', 'hidden', 'active');
|
||||
sh.registerPendingOp(op);
|
||||
|
||||
const initialEmitCount = socketMock.emit.mock.calls.length;
|
||||
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS + 1);
|
||||
expect(socketMock.emit).toHaveBeenCalledTimes(initialEmitCount + 1); // retry
|
||||
|
||||
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS + 1);
|
||||
expect(handler.onRevert).toHaveBeenCalledWith(op);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Toolchain Deviations (Carry Forward from Story 1.1 + 1.2)
|
||||
|
||||
- **ESLint flat config**: `eslint.config.js` (NOT `.eslintrc.js` — ESLint 9 flat config)
|
||||
- **TypeScript**: `moduleResolution: "bundler"` (NOT `"node16"`)
|
||||
- **No foundry-vtt-types package** — FoundryVTT globals declared in `src/types/foundry-globals.d.ts`
|
||||
- **Node**: 24.14.1 / npm 11.4.0
|
||||
- **Test environment**: `happy-dom` (vitest.config.js)
|
||||
- **Named exports only**: `export class StateStore`, never `export default`
|
||||
- **vitest globals: false** — always import `describe`, `it`, `expect`, `vi`, `beforeEach` from `'vitest'`
|
||||
- **Import aliases**: `@src`, `@contracts`, `@utils`, `@tests` available but prefer relative paths in source, aliases in tests if needed
|
||||
|
||||
---
|
||||
|
||||
### Critical Patterns to Follow
|
||||
|
||||
**Constructor side-effect-free rule** (hard):
|
||||
```js
|
||||
// ✅
|
||||
constructor(settings) { this._settings = settings; this._matrix = {}; }
|
||||
init() { const raw = this._settings.get(...); ... }
|
||||
// ❌
|
||||
constructor(settings) { this._onReady = settings.get(...); }
|
||||
```
|
||||
|
||||
**src/core/ import boundary** (hard, wired into eslint.config.js):
|
||||
```js
|
||||
// ✅ StateStore imports
|
||||
import { VISIBILITY_STATES, isValidVisibilityMatrix } from '../contracts/visibility-matrix.js';
|
||||
// ❌ forbidden in src/core/
|
||||
import { FoundryAdapter } from '../foundry/FoundryAdapter.js';
|
||||
```
|
||||
|
||||
**Null not undefined** (all public APIs):
|
||||
```js
|
||||
return this._matrix[userId] ?? null; // ✅
|
||||
return this._matrix[userId]; // ❌ (may return undefined)
|
||||
```
|
||||
|
||||
**Guard clauses, early return**:
|
||||
```js
|
||||
setVisibility(userId, state) {
|
||||
if (!userId) return;
|
||||
if (!VISIBILITY_STATES.includes(state)) return;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Error handling by layer**:
|
||||
- `src/core/` → **throw** (errors are testable contracts)
|
||||
- `src/foundry/` → **catch + console.warn/error('[ScryingPool]', ...)**
|
||||
- `module.js` → **catch init errors + console.error + graceful abort**
|
||||
|
||||
---
|
||||
|
||||
### File Summary
|
||||
|
||||
**New files this story creates:**
|
||||
```
|
||||
src/utils/uuid.js
|
||||
src/core/StateStore.js
|
||||
src/core/SocketHandler.js
|
||||
tests/fixtures/foundry-adapter.js
|
||||
tests/unit/core/StateStore.test.js
|
||||
tests/unit/core/SocketHandler.test.js
|
||||
```
|
||||
|
||||
**Files modified this story:**
|
||||
```
|
||||
src/contracts/socket-message.js ← add baseRevision to SocketIntentPayload
|
||||
src/foundry/FoundryAdapter.js ← constructor(game), add all 6 surfaces
|
||||
module.js ← register remaining settings, construct data layer
|
||||
tests/fixtures/socket-payloads.js ← expand with canonical fixtures per AC-9
|
||||
tests/unit/foundry/FoundryAdapter.test.js ← update constructor calls + add surface tests
|
||||
tests/unit/contracts/socket-message.test.js ← update validIntent fixture for baseRevision
|
||||
```
|
||||
|
||||
**Files unchanged:**
|
||||
```
|
||||
src/contracts/visibility-matrix.js (complete from Story 1.1)
|
||||
src/contracts/pending-op.js (complete from Story 1.1)
|
||||
src/contracts/scene-preset.js (complete from Story 1.1)
|
||||
tests/helpers/foundryAdapterMock.js (canonical mock — do NOT change)
|
||||
tests/fixtures/pending-op.js (complete from Story 1.1)
|
||||
tests/fixtures/state-store-snapshots.js (complete from Story 1.1)
|
||||
tests/fixtures/visibility-states.js (complete from Story 1.1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### References
|
||||
|
||||
- Story 1.3 ACs: `_bmad-output/planning-artifacts/epics.md` lines 303–355
|
||||
- Story 1.4 preview (context only): `_bmad-output/planning-artifacts/epics.md` lines 357–395
|
||||
- FoundryAdapter surface contract: `_bmad-output/planning-artifacts/architecture.md` ~line 292
|
||||
- Initialisation order: `_bmad-output/planning-artifacts/architecture.md` ~line 303
|
||||
- Import boundary rule: `_bmad-output/planning-artifacts/architecture.md` line 428
|
||||
- StateStore sole-writer rule: `_bmad-output/planning-artifacts/architecture.md` line 481
|
||||
- Constructor side-effect-free: `_bmad-output/planning-artifacts/architecture.md` line 487
|
||||
- Error handling by layer: `_bmad-output/planning-artifacts/architecture.md` line 509
|
||||
- Socket event names: `_bmad-output/planning-artifacts/architecture.md` line 384
|
||||
- Naming patterns: `_bmad-output/planning-artifacts/architecture.md` line 360
|
||||
- Test patterns: `_bmad-output/planning-artifacts/architecture.md` line 527
|
||||
- OQ-1 spike outcome: `src/foundry/FoundryAdapter.js` top-of-file comment
|
||||
- Toolchain deviations: `_bmad-output/implementation-artifacts/1-1-module-scaffold-cicd-pipeline-and-design-token-system.md#Dev Notes`
|
||||
|
||||
---
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Sonnet 4.6 (claude-sonnet-4.6)
|
||||
|
||||
### Debug Log References
|
||||
- Task 2: Red test confirmed (`baseRevision` rejected as unknown key before validator update) — TDD red/green cycle validated.
|
||||
- Task 12: Pre-existing `scripts/package.mjs` lint errors (7) and `pending-op.js` duplicate typedef (2) fixed. No new errors introduced.
|
||||
|
||||
### Completion Notes List
|
||||
- ✅ Task 1: `src/utils/uuid.js` — `generateOpId()` using `crypto.randomUUID()` with time-random fallback
|
||||
- ✅ Task 2: `src/contracts/socket-message.js` — `baseRevision` added to typedef, factory, and validator; 17 contract tests pass
|
||||
- ✅ Task 3: `src/foundry/FoundryAdapter.js` — constructor(game), 6 surfaces; `g` typed as `any` for TS compat; 38 adapter tests pass
|
||||
- ✅ Task 4: `src/core/StateStore.js` — full implementation; `Hooks.callAll` used directly (standalone global); 31 tests pass
|
||||
- ✅ Task 5: `src/core/SocketHandler.js` — full implementation with fake-timer timeout/retry/revert; 18 tests pass
|
||||
- ✅ Task 6: `module.js` — `visibilityMatrix` + `showGMSelfFeed` settings registered; adapter/stateStore/socketHandler constructed; `stateStore.init()` called in ready hook; WebRTC probe and fallback
|
||||
- ✅ Task 7: `tests/fixtures/foundry-adapter.js` — `GAME_STUB`, `HOOKS_STUB`, `UI_STUB` + individual stubs exported
|
||||
- ✅ Task 8: `tests/fixtures/socket-payloads.js` — `baseRevision: 0` in `validIntent`; `staleEcho`, `timeoutIntent`, `hydrationPayload`, `missingBaseRevision` added
|
||||
- ✅ Task 9: `tests/unit/foundry/FoundryAdapter.test.js` — 38 tests (18 existing + 20 new surface delegation + constructor tests)
|
||||
- ✅ Task 10: `tests/unit/core/StateStore.test.js` — 31 tests; all VISIBILITY_STATES parametrized
|
||||
- ✅ Task 11: `tests/unit/core/SocketHandler.test.js` — 18 tests with vitest fake timers for all timeout paths
|
||||
- ✅ Task 12: Pipeline green — 144 tests, typecheck clean, lint clean (pre-existing `scripts/package.mjs` warnings only)
|
||||
- Also fixed: pre-existing `src/contracts/pending-op.js` duplicate `@typedef {Object} PendingOp` (TS2300), `src/types/foundry-globals.d.ts` extended with `ui` global declaration
|
||||
|
||||
### File List
|
||||
- `src/utils/uuid.js` (modified)
|
||||
- `src/contracts/socket-message.js` (modified)
|
||||
- `src/contracts/pending-op.js` (modified — pre-existing TS duplicate typedef fixed)
|
||||
- `src/foundry/FoundryAdapter.js` (modified)
|
||||
- `src/core/StateStore.js` (created)
|
||||
- `src/core/SocketHandler.js` (created)
|
||||
- `src/types/foundry-globals.d.ts` (modified — added `ui` declaration)
|
||||
- `module.js` (modified)
|
||||
- `tests/fixtures/foundry-adapter.js` (created)
|
||||
- `tests/fixtures/socket-payloads.js` (modified)
|
||||
- `tests/unit/contracts/socket-message.test.js` (modified)
|
||||
- `tests/unit/foundry/FoundryAdapter.test.js` (modified)
|
||||
- `tests/unit/core/StateStore.test.js` (created)
|
||||
- `tests/unit/core/SocketHandler.test.js` (created)
|
||||
- `_bmad-output/implementation-artifacts/sprint-status.yaml` (modified — status → review)
|
||||
|
||||
### Change Log
|
||||
- Story 1.3 implementation complete — data layer: FoundryAdapter(game), StateStore, SocketHandler — 2025-01-23
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
||||
|
||||
generated: "2026-05-21T01:00:00+02:00"
|
||||
last_updated: "2026-05-22T00:00:00+02:00"
|
||||
last_updated: "2026-05-21T23:16:00+02:00"
|
||||
project: video-view-manager
|
||||
project_key: NOKEY
|
||||
tracking_system: file-system
|
||||
@@ -46,7 +46,7 @@ development_status:
|
||||
epic-1: in-progress
|
||||
1-1-module-scaffold-cicd-pipeline-and-design-token-system: done
|
||||
1-2-webrtc-spike-track-disabling-api-validation: done
|
||||
1-3-data-layer-foundryadapter-statestore-and-socket-infrastructure: backlog
|
||||
1-3-data-layer-foundryadapter-statestore-and-socket-infrastructure: review
|
||||
1-4-core-logic-scryingpoolcontroller-and-visibilitymanager: backlog
|
||||
1-5-gm-control-ui-scryingpoolstrip-actionpopover-and-av-tile-integration: backlog
|
||||
1-6-player-camera-status-badge: backlog
|
||||
|
||||
Reference in New Issue
Block a user