16 KiB
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
-
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 insrc/foundry/FoundryAdapter.jswith the FoundryVTT version tested against. -
Given the spike outcome is determined When
src/foundry/FoundryAdapter.jsis examined Then theFoundryAdapter.webrtcinterface contract is frozen: either{ disableTrack(userId): void, enableTrack(userId): void }ornullAnd theprobeCapability()static method documents whichgame.webrtcproperties were checked and their availability. -
Given the spike file exists When
module.jsruns itsHooks.once('init')handler Thenscrying-pool.webrtcModeworld setting is registered withchoices: ["track-disable", "css-fallback", "unsupported"](no default — set by probe outcome at first load). -
Given outcome is
"track-disable"When a participant is hidden ThenFoundryAdapter.webrtc.disableTrack(userId)disables the inbound track (track.enabled = false) and no inbound video bandwidth is consumed. -
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. -
Given
game.webrtcisnullor absent WhenFoundryAdapter.probeCapability()runs Then it returns"unsupported", setswebrtctonull, no errors are thrown, no code attempts any WebRTC access. -
Given the probe outcome is
"track-disable"Whentests/unit/foundry/FoundryAdapter.test.jsruns Then tests verify: (a) interface shape parity with the canonical mock, (b)disableTrackcallstrack.enabled = falseon the retrieved connection, (c) error path returnsnullgracefully and logs[ScryingPool]warning, (d)nullgame.webrtc path returnsnullwithout errors.
Tasks / Subtasks
-
Task 1: Create
src/foundry/FoundryAdapter.jsskeleton with probe (AC: #1, #2, #6)- 1.1 Create
src/foundry/directory - 1.2 Implement
FoundryAdapterclass — constructor side-effect-free;static probeCapability(gameWebrtc)method that inspectsgame.webrtcand returns one of the three outcome strings - 1.3 Implement
webrtcproperty: if probe outcome is"track-disable", return{ disableTrack(userId), enableTrack(userId) }object; otherwise returnnull - 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
- 1.5 Add outcome comment block:
// OQ-1 Spike Result: <outcome> — FoundryVTT v<version> — <date> — <brief explanation>
- 1.1 Create
-
Task 2: Register
scrying-pool.webrtcModeworld setting inmodule.js(AC: #3)- 2.1 Update
module.jsHooks.once('init')stub to callgame.settings.register('scrying-pool', 'webrtcMode', { ... }) - 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' - 2.3 Add setting key constant to
src/foundry/FoundryAdapter.js:static SETTING_WEBRTC_MODE = 'webrtcMode'(+static SETTINGS_NS = 'scrying-pool')
- 2.1 Update
-
Task 3: Update
tests/helpers/foundryAdapterMock.jsfor webrtc interface (AC: #7)- 3.1 Add
webrtc: nulldefault path (already present — verify it remains the canonical default) - 3.2 Document that overriding webrtc with
{ disableTrack: vi.fn(), enableTrack: vi.fn() }simulates the"track-disable"outcome
- 3.1 Add
-
Task 4: Write
tests/unit/foundry/FoundryAdapter.test.js(AC: #7)- 4.1 Create
tests/unit/foundry/directory - 4.2 Test:
probeCapability(null)returns"unsupported"without errors - 4.3 Test:
probeCapabilitywith a mockgame.webrtcthat hasclient.getMediaStreamForUser→ returns"css-fallback"(OQ-1 spike result: track-disable not achievable) - 4.4 Test:
probeCapabilitywith a mockgame.webrtcthat lacksclient.getMediaStreamForUser→ returns"unsupported" - 4.5 Test:
disableTrack(userId)callstrack.enabled = falseon the resolved track - 4.6 Test:
disableTrack(userId)whengetMediaStreamForUser()returnsnull→ logs[ScryingPool] warnand does not throw - 4.7 Test:
enableTrack(userId)restorestrack.enabled = true - 4.8 Test: FoundryAdapter interface shape matches
createFoundryAdapterMock()surface keys
- 4.1 Create
-
Task 5: Verify pipeline (AC: all)
- 5.1
npm run lintexits 0 —src/foundry/imports onlysrc/contracts/andsrc/utils/ - 5.2
npm run typecheckexits 0 - 5.3
npm run testexits 0 (all FoundryAdapter tests pass)
- 5.1
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:
- A documented, frozen
FoundryAdapter.webrtcinterface decision (track-disableornull) - The minimal
src/foundry/FoundryAdapter.jsskeleton with the probe code - The
scrying-pool.webrtcModesetting 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
// 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.
// 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:
- Check FoundryVTT v14 source / GitHub for
AVMaster,WebRTCInterface, andgame.webrtctype definitions - Check
@league-of-foundry-developers/foundry-vtt-typesfor v14 type stubs (not installed — see Story 1.1 deviation, usesrc/types/foundry-globals.d.ts) - Probe sequence to try in browser console when Foundry is running with AV:
// 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'); } - Document outcome as a comment at the top of
FoundryAdapter.js
probeCapability() Logic Pattern
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)
// 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:
// 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 inscripts/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', ...)andSETTING_WEBRTC_MODE = 'video-view-manager.webrtcMode'. Architecture convention specifiesscrying-pool.{key}namespace — corrected togame.settings.register('scrying-pool', 'webrtcMode', ...)andSETTING_WEBRTC_MODE = 'webrtcMode'+SETTINGS_NS = 'scrying-pool'. - Task 4.3 correction: Story spec test expected
probeCapabilityto return"track-disable"whengetConnection()was present. Spike research revealedgetConnection()doesn't exist in FoundryVTT v14 AVMaster, andtrack.enabled = falsedoesn't stop bandwidth. Test updated to reflect spike outcome:client.getMediaStreamForUserpresent →"css-fallback".
Completion Notes List
- OQ-1 resolved:
css-fallback— FoundryVTT v14 AVMaster has nogetConnection(userId)method. Remote stream access goes viagame.webrtc.client.getMediaStreamForUser(userId)(public AVClient abstract API).track.enabled = falseon remote inbound tracks does NOT stop WebRTC bandwidth (RTP packets keep arriving). CSS/DOM cosmetic hiding is the honest implementation path.FoundryAdapter.webrtc = nullin production. src/foundry/FoundryAdapter.jscreated: 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.jsupdated:Hooks.once('init')now registersscrying-pool.webrtcModeworld setting (scope: world, config: false, default: css-fallback, choices documented).tests/helpers/foundryAdapterMock.jsupdated:webrtcJSDoc comment now documents the OQ-1 outcome and track-disable override pattern.tests/unit/foundry/FoundryAdapter.test.jscreated: 18 tests — 5 forprobeCapability, 7 forbuildWebRTCSurface(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— CREATEDtests/unit/foundry/FoundryAdapter.test.js— CREATEDmodule.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)