# Edge Case Hunter — WebRTC Full AV Replacement Code Review **Diff Source:** Commit range HEAD~1..HEAD (c4a375f) **Review Target:** WebRTC full AV replacement implementation **Date:** 2026-05-26 --- ## INSTRUCTIONS You are the **Edge Case Hunter** — a pure path tracer. Never comment on whether code is good or bad; only list missing handling. **YOU RECEIVE:** 1. The diff below (primary scope) 2. Read access to the project codebase at `/home/morr/work/foundryvtt/video-view-manager/` **YOUR METHOD:** Exhaustive path enumeration — mechanically walk every branch, not hunt by intuition. Report ONLY paths and conditions that lack handling — discard handled ones silently. DO NOT editorialize or add filler — findings only. **SCOPE RULES:** - Scan only the diff hunks - List boundaries that are directly reachable from the changed lines and lack an explicit guard in the diff - Ignore the rest of the codebase unless the diff explicitly references external functions ## EXECUTION ### Step 1: Receive Content The diff below is your scope. ### Step 2: Exhaustive Path Analysis Walk all branching paths and domain boundaries within the diff. For each path, determine whether it is handled. Collect only the UNHANDLED paths. Edge classes to consider (derive from content, not fixed checklist): - missing else/default branches - null/empty/undefined inputs - off-by-one errors in loops - arithmetic overflow/underflow - implicit type coercion - race conditions - timeout gaps - error handling gaps - boundary conditions (min/max values) ### Step 3: Validate Completeness Revisit every edge class — add newly found unhandled paths, discard confirmed-handled ones. ### Step 4: Present Findings Output findings as a JSON array following the format below. ## OUTPUT FORMAT Return ONLY a valid JSON array. No extra text, no explanations, no markdown wrapping. ```json [{ "location": "file:start-end (or file:line when single line, or file:hunk when exact line unavailable)", "trigger_condition": "one-line description (max 15 words)", "guard_snippet": "minimal code sketch that closes the gap (single-line escaped string, no raw newlines or unescaped quotes)", "potential_consequence": "what could actually go wrong (max 15 words)" }] ``` An empty array `[]` is valid when no unhandled paths are found. --- ## DIFF TO REVIEW diff --git a/module.js b/module.js index 86d48ac..c5fe82f 100644 --- a/module.js +++ b/module.js @@ -57,15 +57,17 @@ let directorsBoardButtonAdded = false; Hooks.once("init", () => { console.log("[ScryingPool] init — module loading"); - // OQ-1 resolved (Story 1.2 spike): probe result is 'css-fallback' — see FoundryAdapter.js + // WebRTC mode setting — determines how the module handles AV integration + // Updated for FULL REPLACEMENT architecture (hiding Foundry's dock, showing our own) game.settings.register("scrying-pool", "webrtcMode", { scope: "world", config: false, type: String, - default: "css-fallback", + default: "stream-access", choices: { - "track-disable": "Track Disable (bandwidth-saving)", - "css-fallback": "CSS Fallback (cosmetic hiding)", + "stream-access": "Stream Access (full replacement - hide Foundry dock, show VVM dock with actual video)", + "track-disable": "Track Disable (bandwidth-saving) - DEPRECATED", + "css-fallback": "CSS Fallback (cosmetic hiding) - DEPRECATED", "unsupported": "Unsupported (AV not available)", }, }); @@ -175,14 +177,25 @@ Hooks.once("ready", () => { // Hydrate StateStore from persisted world setting (AC-6, AC-7) stateStore.init(); - // Probe WebRTC capability and set adapter.webrtc (AC-8) + // Probe WebRTC capability and set adapter.webrtc + // For FULL REPLACEMENT: we need stream access to create our own video tiles const outcome = FoundryAdapter.probeCapability(game.webrtc); - adapter.webrtc = outcome === 'track-disable' - ? FoundryAdapter.buildWebRTCSurface(game.webrtc) - : null; adapter.settings.set(FoundryAdapter.SETTING_WEBRTC_MODE, outcome).catch(err => { console.error('[ScryingPool] Failed to set webrtcMode setting:', err); }); + + // Build WebRTC surface for stream access (full replacement mode) + if (outcome === 'stream-access') { + adapter.webrtc = FoundryAdapter.buildWebRTCSurface(game.webrtc); + console.log('[ScryingPool] WebRTC stream access available - full replacement mode enabled'); + } else if (outcome === 'unsupported') { + adapter.webrtc = null; + console.log('[ScryingPool] WebRTC not available - AV features disabled'); + } else { + // Legacy: track-disable or css-fallback (deprecated) + adapter.webrtc = FoundryAdapter.buildWebRTCSurface(game.webrtc); + console.warn('[ScryingPool] WebRTC mode is deprecated:', outcome, '- consider using stream-access'); + } // Wire core managers — construct both before setReady so handler can reference both visibilityManager = new VisibilityManager(stateStore, adapter); diff --git a/scripts/test-stream-access.mjs b/scripts/test-stream-access.mjs new file mode 100644 index 0000000..8d91db7 --- /dev/null +++ b/scripts/test-stream-access.mjs @@ -0,0 +1,159 @@ +/** + * Test script to verify WebRTC stream access implementation + * This script tests the full AV replacement architecture + * + * Run with: node scripts/test-stream-access.mjs + * + * Tests: + * 1. FoundryAdapter.probeCapability() returns 'stream-access' when getMediaStreamForUser is available + * 2. FoundryAdapter.buildWebRTCSurface() creates proper WebRTC surface + * 3. ScryingPoolStrip.getData() includes hasStreamAccess flag + * 4. buildParticipantList() includes hasStreamAccess in participant objects + * 5. CSS includes rules to hide Foundry's AV dock + * 6. Template includes video container element + */ + +import { FoundryAdapter } from '../src/foundry/FoundryAdapter.js'; +import { buildParticipantList } from '../src/ui/gm/ScryingPoolStrip.js'; + +console.log('=== WebRTC Stream Access Implementation Tests ===\n'); + +// Test 1: probeCapability returns 'stream-access' when getMediaStreamForUser is available +console.log('Test 1: probeCapability with stream access'); +const mockWebRTCWithStream = { + client: { + getMediaStreamForUser: (userId) => new MediaStream(), + getConnectedUsers: () => ['user1', 'user2'], + }, +}; +const result1 = FoundryAdapter.probeCapability(mockWebRTCWithStream); +console.log(` probeCapability result: ${result1}`); +console.assert(result1 === 'stream-access', 'Should return stream-access'); +console.log(' ✓ PASSED\n'); + +// Test 2: probeCapability returns 'unsupported' when getMediaStreamForUser is missing +console.log('Test 2: probeCapability without stream access'); +const mockWebRTCWithoutStream = { + client: {}, +}; +const result2 = FoundryAdapter.probeCapability(mockWebRTCWithoutStream); +console.log(` probeCapability result: ${result2}`); +console.assert(result2 === 'unsupported', 'Should return unsupported'); +console.log(' ✓ PASSED\n'); + +// Test 3: buildWebRTCSurface creates proper surface with all methods +console.log('Test 3: buildWebRTCSurface creates complete WebRTC surface'); +const surface = FoundryAdapter.buildWebRTCSurface(mockWebRTCWithStream); +console.log(' WebRTC surface methods:'); +console.log(' - getMediaStreamForUser:', typeof surface.getMediaStreamForUser === 'function' ? '✓' : '✗'); +console.log(' - getConnectedUsers:', typeof surface.getConnectedUsers === 'function' ? '✓' : '✗'); +console.log(' - getLevelsStreamForUser:', typeof surface.getLevelsStreamForUser === 'function' ? '✓' : '✗'); +console.log(' - isAudioEnabled:', typeof surface.isAudioEnabled === 'function' ? '✓' : '✗'); +console.log(' - isVideoEnabled:', typeof surface.isVideoEnabled === 'function' ? '✓' : '✗'); +console.log(' - toggleAudio:', typeof surface.toggleAudio === 'function' ? '✓' : '✗'); +console.log(' - toggleVideo:', typeof surface.toggleVideo === 'function' ? '✓' : '✗'); +console.log(' - toggleBroadcast:', typeof surface.toggleBroadcast === 'function' ? '✓' : '✗'); +console.log(' - setUserVideo:', typeof surface.setUserVideo === 'function' ? '✓' : '✗'); +console.log(' - disableTrack:', typeof surface.disableTrack === 'function' ? '✓' : '✗'); +console.log(' - enableTrack:', typeof surface.enableTrack === 'function' ? '✓' : '✗'); + +const allMethodsPresent = + typeof surface.getMediaStreamForUser === 'function' && + typeof surface.getConnectedUsers === 'function' && + typeof surface.getLevelsStreamForUser === 'function' && + typeof surface.isAudioEnabled === 'function' && + typeof surface.isVideoEnabled === 'function' && + typeof surface.toggleAudio === 'function' && + typeof surface.toggleVideo === 'function' && + typeof surface.toggleBroadcast === 'function' && + typeof surface.setUserVideo === 'function' && + typeof surface.disableTrack === 'function' && + typeof surface.enableTrack === 'function'; + +console.assert(allMethodsPresent, 'All WebRTC surface methods should be functions'); +console.log(' ✓ PASSED\n'); + +// Test 4: buildParticipantList includes hasStreamAccess +console.log('Test 4: buildParticipantList includes hasStreamAccess flag'); +const mockAdapter = { + users: { + get: (userId) => ({ name: userId, avatar: null }), + }, +}; +const mockStateStore = { + getState: (userId) => 'active', +}; +const mockController = { + hasPendingOp: (userId) => false, +}; + +const participants = buildParticipantList( + ['user1', 'user2'], + mockStateStore, + mockController, + mockAdapter, + true // hasStreamAccess +); + +console.log(` Participant count: ${participants.length}`); +console.log(` First participant hasStreamAccess: ${participants[0].hasStreamAccess}`); +console.assert(participants.length === 2, 'Should have 2 participants'); +console.assert(participants[0].hasStreamAccess === true, 'Participant should have hasStreamAccess flag'); +console.log(' ✓ PASSED\n'); + +// Test 5: CSS includes rules to hide Foundry's AV dock +console.log('Test 5: CSS includes AV dock hiding rules'); +import fs from 'fs'; +import path from 'path'; + +const cssPath = path.join(process.cwd(), 'styles', 'scrying-pool.less'); +const cssContent = fs.readFileSync(cssPath, 'utf8'); + +const hasAvHiding = cssContent.includes('#av') && cssContent.includes('display: none'); +const hasCameraViewHiding = cssContent.includes('.camera-view') && cssContent.includes('display: none'); + +console.log(` Has #av hiding rule: ${hasAvHiding ? '✓' : '✗'}`); +console.log(` Has .camera-view hiding rule: ${hasCameraViewHiding ? '✓' : '✗'}`); +console.assert(hasAvHiding, 'CSS should include #av hiding rule'); +console.assert(hasCameraViewHiding, 'CSS should include .camera-view hiding rule'); +console.log(' ✓ PASSED\n'); + +// Test 6: Template includes video container +console.log('Test 6: Template includes video container element'); +const templatePath = path.join(process.cwd(), 'templates', 'roster-strip.hbs'); +const templateContent = fs.readFileSync(templatePath, 'utf8'); + +const hasVideoContainer = templateContent.includes('sp-participant-video'); +const hasStreamAccessCheck = templateContent.includes('hasStreamAccess'); + +console.log(` Has video container class: ${hasVideoContainer ? '✓' : '✗'}`); +console.log(` Has stream access check: ${hasStreamAccessCheck ? '✓' : '✗'}`); +console.assert(hasVideoContainer, 'Template should include video container'); +console.assert(hasStreamAccessCheck, 'Template should check for stream access'); +console.log(' ✓ PASSED\n'); + +// Test 7: CSS includes video element styling +console.log('Test 7: CSS includes video element styling'); +const rosterCssPath = path.join(process.cwd(), 'styles', 'components', '_roster-strip.less'); +const rosterCssContent = fs.readFileSync(rosterCssPath, 'utf8'); + +const hasVideoContainerClass = rosterCssContent.includes('.sp-participant-video'); +const hasVideoElementClass = rosterCssContent.includes('.sp-participant-video__element'); + +console.log(` Has .sp-participant-video class: ${hasVideoContainerClass ? '✓' : '✗'}`); +console.log(` Has .sp-participant-video__element class: ${hasVideoElementClass ? '✓' : '✗'}`); +console.assert(hasVideoContainerClass, 'CSS should include video container class'); +console.assert(hasVideoElementClass, 'CSS should include video element class'); +console.log(' ✓ PASSED\n'); + +// Summary +console.log('=== All Tests Passed! ==='); +console.log('\nImplementation Summary:'); +console.log('✓ FoundryAdapter.probeCapability() correctly identifies stream-access mode'); +console.log('✓ FoundryAdapter.buildWebRTCSurface() provides full WebRTC client API'); +console.log('✓ ScryingPoolStrip.getData() includes hasStreamAccess flag'); +console.log('✓ buildParticipantList() passes hasStreamAccess to participants'); +console.log('✓ CSS hides Foundry AV dock (#av and .camera-view)'); +console.log('✓ Template includes video container with conditional rendering'); +console.log('✓ CSS includes styling for video containers and elements'); +console.log('\nFull AV replacement architecture is implemented and ready!'); diff --git a/src/foundry/FoundryAdapter.js b/src/foundry/FoundryAdapter.js index 035b75a..680a063 100644 --- a/src/foundry/FoundryAdapter.js +++ b/src/foundry/FoundryAdapter.js @@ -308,51 +308,183 @@ export class FoundryAdapter { } /** - * Probes the game.webrtc (AVMaster) instance for WebRTC track-disabling capability. + * Probes the game.webrtc (AVMaster) instance for WebRTC capability. * * Probe logic and results (FoundryVTT v14, 2026-05-21): * - If game.webrtc is null/falsy → AV disabled or not yet initialised → `'unsupported'` * - If game.webrtc is not an object → invalid type → `'unsupported'` * - If game.webrtc.client lacks getMediaStreamForUser() → non-standard backend → `'unsupported'` - * - Otherwise: tracks are technically reachable but track.enabled = false does NOT reduce - * inbound WebRTC bandwidth (RTP packets keep arriving). The `'track-disable'` outcome - * requires true bandwidth elimination, so the result is `'css-fallback'`. + * - Otherwise: getMediaStreamForUser() is available for stream access → `'stream-access'` * - * The `'track-disable'` branch in buildWebRTCSurface is kept for forward compatibility - * in case a future FoundryVTT version or custom AVClient exposes real bandwidth control. + * NOTE: For FULL REPLACEMENT architecture (hiding Foundry's AV dock and showing our own): + * - We use 'stream-access' mode to get actual MediaStream objects via getMediaStreamForUser() + * - We create our own