290 lines
16 KiB
Markdown
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)
|