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

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

  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

  • Task 1: Create src/foundry/FoundryAdapter.js skeleton with probe (AC: #1, #2, #6)

    • 1.1 Create src/foundry/ directory
    • 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
    • 1.3 Implement webrtc property: if probe outcome is "track-disable", return { disableTrack(userId), enableTrack(userId) } object; otherwise return null
    • 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>
  • Task 2: Register scrying-pool.webrtcMode world setting in module.js (AC: #3)

    • 2.1 Update module.js Hooks.once('init') stub to call game.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')
  • Task 3: Update tests/helpers/foundryAdapterMock.js for webrtc interface (AC: #7)

    • 3.1 Add webrtc: null default 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
  • 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: probeCapability with a mock game.webrtc that has client.getMediaStreamForUser → returns "css-fallback" (OQ-1 spike result: track-disable not achievable)
    • 4.4 Test: probeCapability with a mock game.webrtc that lacks client.getMediaStreamForUser → returns "unsupported"
    • 4.5 Test: disableTrack(userId) calls track.enabled = false on the resolved track
    • 4.6 Test: disableTrack(userId) when getMediaStreamForUser() returns null → logs [ScryingPool] warn and does not throw
    • 4.7 Test: enableTrack(userId) restores track.enabled = true
    • 4.8 Test: FoundryAdapter interface shape matches createFoundryAdapterMock() surface keys
  • Task 5: Verify pipeline (AC: all)

    • 5.1 npm run lint exits 0 — src/foundry/ imports only src/contracts/ and src/utils/
    • 5.2 npm run typecheck exits 0
    • 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

// 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:

  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:
    // 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

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 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)