# Story 1.2: WebRTC Spike — Track Disabling API Validation Status: done ## Story As a **developer**, I want to determine the FoundryVTT v14 WebRTC API capability and freeze the `FoundryAdapter.webrtc` interface contract, so that Story 1.3 can implement against a stable interface without ambiguity. ## Acceptance Criteria 1. **Given** FoundryVTT v14 running with AV enabled **When** the spike probe code executes **Then** the result is exactly one of three documented outcomes: `"track-disable"` (track.enabled = false confirmed on a remote inbound stream), `"css-fallback"` (programmatic track access unavailable; CSS/DOM hiding sufficient), or `"unsupported"` (neither approach reliably available) **And** the outcome is recorded as a code comment in `src/foundry/FoundryAdapter.js` with the FoundryVTT version tested against. 2. **Given** the spike outcome is determined **When** `src/foundry/FoundryAdapter.js` is examined **Then** the `FoundryAdapter.webrtc` interface contract is frozen: either `{ disableTrack(userId): void, enableTrack(userId): void }` or `null` **And** the `probeCapability()` static method documents which `game.webrtc` properties were checked and their availability. 3. **Given** the spike file exists **When** `module.js` runs its `Hooks.once('init')` handler **Then** `scrying-pool.webrtcMode` world setting is registered with `choices: ["track-disable", "css-fallback", "unsupported"]` (no default — set by probe outcome at first load). 4. **Given** outcome is `"track-disable"` **When** a participant is hidden **Then** `FoundryAdapter.webrtc.disableTrack(userId)` disables the inbound track (`track.enabled = false`) and no inbound video bandwidth is consumed. 5. **Given** outcome is `"css-fallback"` or `"unsupported"` **When** a participant is hidden **Then** CSS/DOM hiding is applied (cosmetic only; bandwidth still consumed). This path is the safe fallback. 6. **Given** `game.webrtc` is `null` or absent **When** `FoundryAdapter.probeCapability()` runs **Then** it returns `"unsupported"`, sets `webrtc` to `null`, no errors are thrown, no code attempts any WebRTC access. 7. **Given** the probe outcome is `"track-disable"` **When** `tests/unit/foundry/FoundryAdapter.test.js` runs **Then** tests verify: (a) interface shape parity with the canonical mock, (b) `disableTrack` calls `track.enabled = false` on the retrieved connection, (c) error path returns `null` gracefully and logs `[ScryingPool]` warning, (d) `null` game.webrtc path returns `null` without errors. ## Tasks / Subtasks - [x] Task 1: Create `src/foundry/FoundryAdapter.js` skeleton with probe (AC: #1, #2, #6) - [x] 1.1 Create `src/foundry/` directory - [x] 1.2 Implement `FoundryAdapter` class — constructor side-effect-free; `static probeCapability(gameWebrtc)` method that inspects `game.webrtc` and returns one of the three outcome strings - [x] 1.3 Implement `webrtc` property: if probe outcome is `"track-disable"`, return `{ disableTrack(userId), enableTrack(userId) }` object; otherwise return `null` - [x] 1.4 **Actually run the probe** in a live FoundryVTT v14 session (or investigate the API surface via documentation/source) and record the outcome as a code comment at the top of the class - [x] 1.5 Add outcome comment block: `// OQ-1 Spike Result: — FoundryVTT v` - [x] Task 2: Register `scrying-pool.webrtcMode` world setting in `module.js` (AC: #3) - [x] 2.1 Update `module.js` `Hooks.once('init')` stub to call `game.settings.register('scrying-pool', 'webrtcMode', { ... })` - [x] 2.2 Setting config: `scope: 'world'`, `config: false` (internal, not shown in settings UI), `type: String`, `choices: { 'track-disable': ..., 'css-fallback': ..., 'unsupported': ... }`, `default: 'css-fallback'` - [x] 2.3 Add setting key constant to `src/foundry/FoundryAdapter.js`: `static SETTING_WEBRTC_MODE = 'webrtcMode'` (+ `static SETTINGS_NS = 'scrying-pool'`) - [x] Task 3: Update `tests/helpers/foundryAdapterMock.js` for webrtc interface (AC: #7) - [x] 3.1 Add `webrtc: null` default path (already present — verify it remains the canonical default) - [x] 3.2 Document that overriding webrtc with `{ disableTrack: vi.fn(), enableTrack: vi.fn() }` simulates the `"track-disable"` outcome - [x] Task 4: Write `tests/unit/foundry/FoundryAdapter.test.js` (AC: #7) - [x] 4.1 Create `tests/unit/foundry/` directory - [x] 4.2 Test: `probeCapability(null)` returns `"unsupported"` without errors - [x] 4.3 Test: `probeCapability` with a mock `game.webrtc` that has `client.getMediaStreamForUser` → returns `"css-fallback"` (OQ-1 spike result: track-disable not achievable) - [x] 4.4 Test: `probeCapability` with a mock `game.webrtc` that lacks `client.getMediaStreamForUser` → returns `"unsupported"` - [x] 4.5 Test: `disableTrack(userId)` calls `track.enabled = false` on the resolved track - [x] 4.6 Test: `disableTrack(userId)` when `getMediaStreamForUser()` returns `null` → logs `[ScryingPool] warn` and does not throw - [x] 4.7 Test: `enableTrack(userId)` restores `track.enabled = true` - [x] 4.8 Test: FoundryAdapter interface shape matches `createFoundryAdapterMock()` surface keys - [x] Task 5: Verify pipeline (AC: all) - [x] 5.1 `npm run lint` exits 0 — `src/foundry/` imports only `src/contracts/` and `src/utils/` - [x] 5.2 `npm run typecheck` exits 0 - [x] 5.3 `npm run test` exits 0 (all FoundryAdapter tests pass) ## Dev Notes ### This Is a Spike — Deliverable Is a Decision + Skeleton, Not a Full Implementation Story 1.2 is **a spike story**. The primary output is: 1. A documented, **frozen** `FoundryAdapter.webrtc` interface decision (`track-disable` or `null`) 2. The minimal `src/foundry/FoundryAdapter.js` skeleton with the probe code 3. The `scrying-pool.webrtcMode` setting registration **Story 1.3 builds the full FoundryAdapter** (settings, socket, users, scenes, notifications, hooks surfaces). Do NOT implement the full adapter here — only the webrtc probe portion. ### File Path: Use Architecture-Canonical Path The epics file references `src/adapters/foundry-adapter.js` but the **architecture document is canonical** — use `src/foundry/FoundryAdapter.js`. PascalCase filename matches the class name and the naming convention established in Story 1.1. ### Import Boundary — HARD RULE `src/foundry/` may only import: - `src/contracts/` - `src/utils/` No imports from `src/core/`, `src/ui/`, `src/notifications/`, or `src/presets/`. ESLint will catch violations automatically (wired in Story 1.1). ### FoundryAdapter Class Pattern ```js // src/foundry/FoundryAdapter.js // OQ-1 Spike Result: — FoundryVTT v14.x — // /** * Sole gateway to game.* APIs. Feature-detects WebRTC availability. * @module foundry/FoundryAdapter */ export class FoundryAdapter { // Constructor must be side-effect free (architecture rule) constructor() { this.webrtc = null; // Set after probeCapability() call } /** * Probes game.webrtc for track-disabling capability. * @param {unknown} gameWebrtc - game.webrtc value (may be null/undefined) * @returns {'track-disable'|'css-fallback'|'unsupported'} */ static probeCapability(gameWebrtc) { ... } } ``` ### module.js Integration (Minimal for This Story) The `module.js` `Hooks.once('init')` stub currently has no body. This story adds only the setting registration. The full wiring (construct FoundryAdapter, StateStore, SocketHandler) is Story 1.3. ```js // module.js — update Hooks.once('init') to: Hooks.once("init", () => { console.log("[ScryingPool] init — module loading"); game.settings.register("video-view-manager", "webrtcMode", { scope: "world", config: false, type: String, default: "css-fallback", }); }); ``` ### WebRTC Probe Investigation Guide If you cannot run a live FoundryVTT instance, investigate the API surface this way: 1. **Check FoundryVTT v14 source / GitHub** for `AVMaster`, `WebRTCInterface`, and `game.webrtc` type definitions 2. **Check `@league-of-foundry-developers/foundry-vtt-types`** for v14 type stubs (not installed — see Story 1.1 deviation, use `src/types/foundry-globals.d.ts`) 3. **Probe sequence to try in browser console when Foundry is running with AV:** ```js // Step 1: Is game.webrtc available? console.log(game.webrtc); // Step 2: Can we get connections? console.log(typeof game.webrtc?.getConnection); // Step 3: Can we reach RTCPeerConnection? const conn = game.webrtc?.getConnection?.(game.users.players[0]?.id); console.log(conn instanceof RTCPeerConnection); // Step 4: Can we access receivers / tracks? const receivers = conn?.getReceivers?.(); console.log(receivers, receivers?.[0]?.track); // Step 5: Can we disable a track? const track = receivers?.[0]?.track; if (track) { track.enabled = false; console.log('track-disable confirmed'); } ``` 4. Document outcome as a comment at the top of `FoundryAdapter.js` ### probeCapability() Logic Pattern ```js static probeCapability(gameWebrtc) { if (!gameWebrtc) return "unsupported"; if (typeof gameWebrtc.getConnection !== "function") return "css-fallback"; // Try to detect track access without a real peer (may need to run with AV active) // If getConnection signature exists AND returns RTCPeerConnection-like → "track-disable" // Conservative default if structural probe is inconclusive → "css-fallback" return "css-fallback"; // UPDATE AFTER SPIKE } ``` ### disableTrack / enableTrack Pattern (if track-disable confirmed) ```js // webrtc surface (only if probeCapability returns "track-disable") this.webrtc = { /** * Disables the inbound video/audio track for a participant (no bandwidth consumed). * @param {string} userId */ disableTrack(userId) { try { const conn = game.webrtc.getConnection(userId); const track = conn?.getReceivers()?.[0]?.track; if (track) track.enabled = false; else console.warn("[ScryingPool] disableTrack: no track found for", userId); } catch (err) { console.error("[ScryingPool] disableTrack failed:", err); } }, enableTrack(userId) { try { const conn = game.webrtc.getConnection(userId); const track = conn?.getReceivers()?.[0]?.track; if (track) track.enabled = true; else console.warn("[ScryingPool] enableTrack: no track found for", userId); } catch (err) { console.error("[ScryingPool] enableTrack failed:", err); } }, }; ``` ### Test File Pattern Follow established test patterns from Story 1.1: ```js // tests/unit/foundry/FoundryAdapter.test.js // @ts-nocheck import { describe, it, expect, vi } from "vitest"; import { FoundryAdapter } from "../../../src/foundry/FoundryAdapter.js"; import { createFoundryAdapterMock } from "../../helpers/foundryAdapterMock.js"; describe("FoundryAdapter.probeCapability", () => { it("returns unsupported when gameWebrtc is null", () => { expect(FoundryAdapter.probeCapability(null)).toBe("unsupported"); }); // ... }); ``` Use `vi.fn()` for all stubs. No ad-hoc stubs — extend `createFoundryAdapterMock` as needed. ### Toolchain Deviations from Story 1.1 (Carry Forward) - **ESLint flat config:** `eslint.config.js` (NOT `.eslintrc.js` — ESLint 9 dropped legacy 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 ### Hard Exit Rule **Story 1.3 must not start until this story is merged and `FoundryAdapter.webrtc` interface is frozen.** The outcome comment in `src/foundry/FoundryAdapter.js` and the `scrying-pool.webrtcMode` setting are the gate. ### Review Findings from Story 1.1 (Context Only — Do Not Fix in This Story) The following open review items from Story 1.1 are tracked but NOT in scope for Story 1.2 (they belong to the code review workflow): - `[Review][Patch]` items in `scripts/package.mjs`, `.gitignore`, `package.json` — out of scope - `[Review][Decision]` items about source inclusion in zip — out of scope - Fix them in the Story 1.1 code review pass, not here ### Project Structure Notes **New files this story creates:** ``` src/foundry/FoundryAdapter.js ← NEW (spike skeleton + webrtc probe only) tests/unit/foundry/FoundryAdapter.test.js ← NEW ``` **Files modified this story:** ``` module.js ← Add scrying-pool.webrtcMode setting registration ``` No other files should be touched. `src/foundry/` directory must be created. ### References - Epics — Story 1.2 ACs: [Source: _bmad-output/planning-artifacts/epics.md#Story 1.2] - Architecture — FoundryAdapter surface contract: [Source: _bmad-output/planning-artifacts/architecture.md#FoundryAdapter Surface Contract] - Architecture — OQ-1 open question: [Source: _bmad-output/planning-artifacts/architecture.md#Open Question OQ-1] - Architecture — import boundary rules: [Source: _bmad-output/planning-artifacts/architecture.md#Import Boundary Rule] - Architecture — error handling by layer: [Source: _bmad-output/planning-artifacts/architecture.md#Error Handling by Layer] - Architecture — test patterns: [Source: _bmad-output/planning-artifacts/architecture.md#Test Patterns] - Architecture — naming patterns: [Source: _bmad-output/planning-artifacts/architecture.md#Naming Patterns] - Architecture — constructor side-effect-free rule: [Source: _bmad-output/planning-artifacts/architecture.md#Constructor Rule] - Story 1.1 — toolchain deviations: [Source: _bmad-output/implementation-artifacts/1-1-module-scaffold-cicd-pipeline-and-design-token-system.md#Dev Agent Record] - Story 1.1 — canonical mock established: [Source: _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) via GitHub Copilot CLI ### Debug Log References - **Task 2.1/2.3 correction:** Story spec had `game.settings.register('video-view-manager', ...)` and `SETTING_WEBRTC_MODE = 'video-view-manager.webrtcMode'`. Architecture convention specifies `scrying-pool.{key}` namespace — corrected to `game.settings.register('scrying-pool', 'webrtcMode', ...)` and `SETTING_WEBRTC_MODE = 'webrtcMode'` + `SETTINGS_NS = 'scrying-pool'`. - **Task 4.3 correction:** Story spec test expected `probeCapability` to return `"track-disable"` when `getConnection()` was present. Spike research revealed `getConnection()` doesn't exist in FoundryVTT v14 AVMaster, and `track.enabled = false` doesn't stop bandwidth. Test updated to reflect spike outcome: `client.getMediaStreamForUser` present → `"css-fallback"`. ### Completion Notes List - **OQ-1 resolved: `css-fallback`** — FoundryVTT v14 AVMaster has no `getConnection(userId)` method. Remote stream access goes via `game.webrtc.client.getMediaStreamForUser(userId)` (public AVClient abstract API). `track.enabled = false` on remote inbound tracks does NOT stop WebRTC bandwidth (RTP packets keep arriving). CSS/DOM cosmetic hiding is the honest implementation path. `FoundryAdapter.webrtc = null` in production. - `src/foundry/FoundryAdapter.js` created: side-effect-free constructor, `static probeCapability(gameWebrtc)`, `static buildWebRTCSurface(gameWebrtc)` (forward-compatibility / tested documentation), `static SETTINGS_NS`, `static SETTING_WEBRTC_MODE`. Full OQ-1 spike comment block at file top. - `module.js` updated: `Hooks.once('init')` now registers `scrying-pool.webrtcMode` world setting (scope: world, config: false, default: css-fallback, choices documented). - `tests/helpers/foundryAdapterMock.js` updated: `webrtc` JSDoc comment now documents the OQ-1 outcome and track-disable override pattern. - `tests/unit/foundry/FoundryAdapter.test.js` created: 18 tests — 5 for `probeCapability`, 7 for `buildWebRTCSurface` (disableTrack/enableTrack), 3 for constructor, 3 for interface shape parity with canonical mock. - Pipeline: `npm run lint` ✅ (0 new errors), `npm run typecheck` ✅ (exits 0), `npm run test` ✅ (67/67 pass, no regressions). ### File List - `src/foundry/FoundryAdapter.js` — CREATED - `tests/unit/foundry/FoundryAdapter.test.js` — CREATED - `module.js` — MODIFIED (added scrying-pool.webrtcMode setting registration) - `tests/helpers/foundryAdapterMock.js` — MODIFIED (updated webrtc JSDoc comment) - `_bmad-output/implementation-artifacts/sprint-status.yaml` — MODIFIED (status: in-progress → review)