Files
scrying-pool/_bmad-output/implementation-artifacts/1-2-webrtc-spike-track-disabling-api-validation.md
2026-05-21 23:08:34 +02:00

290 lines
16 KiB
Markdown

# 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: <outcome> — FoundryVTT v<version> — <date> — <brief explanation>`
- [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: <outcome> — FoundryVTT v14.x — <date>
// <brief explanation of what was found>
/**
* 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)