Story 4.1: Task 1 Complete - PlayerPrivacyManager Core Logic

- Created src/contracts/privacy-settings.js with:
  - PrivacySettings typedef
  - PRIVACY_SETTINGS_DEFAULT (both flags false)
  - PRIVACY_SETTING_KEYS and FEATURE_NAME_MAP constants
  - createPrivacySettings() factory
  - isValidPrivacySettings() validator
  - validateSettingKey(), validateSettingValue(), validateFeatureName() helpers
- Created src/core/PlayerPrivacyManager.js with:
  - Constructor with FoundryAdapter DI validation
  - getSettings(userId) - retrieves settings from user flags
  - setSetting(userId, key, value) - async, validates, persists via user.setFlag
  - isOptedIn(userId, feature) - convenience method for feature checks
  - getAllSettings() - aggregates all users' settings (GM view)
  - onChange(callback) - subscription pattern for change events
  - teardown() - cleanup
- Created tests/unit/contracts/privacy-settings.test.js - 44 tests
- Created tests/unit/core/PlayerPrivacyManager.test.js - 35 tests
- All tests passing, lint clean
- Updated sprint-status.yaml: 4-1 from ready-for-dev to in-progress
- Updated story file: Task 1 subtasks 1.1-1.8 marked complete

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-05-23 21:11:55 +02:00
parent e81c05a3db
commit 61f362004e
13 changed files with 1971 additions and 29 deletions
@@ -1,6 +1,6 @@
# Story 4.1: Player Privacy Panel & Automation Opt-ins
**Status:** ready-for-dev
**Status:** in-progress
**Epic:** 4 - Player Privacy Panel
@@ -20,7 +20,7 @@
| **Story ID** | 4.1 |
| **Story Key** | 4-1-player-privacy-panel-and-automation-opt-ins |
| **Title** | Player Privacy Panel & Automation Opt-ins |
| **Status** | ready-for-dev |
| **Status** | in-progress |
| **Priority** | High |
| **Assigned Agent** | DEV (Amelia) |
| **Created** | 2026-05-24 |
@@ -126,30 +126,31 @@
**Files:** `src/core/PlayerPrivacyManager.js`, `src/contracts/privacy-settings.js`, `tests/unit/core/PlayerPrivacyManager.test.js`
**Subtasks:**
- [ ] 1.1: Create `src/contracts/privacy-settings.js` with opt-in flag contracts
- [x] 1.1: Create `src/contracts/privacy-settings.js` with opt-in flag contracts
- Define canonical shape for privacy settings: `{ reactionCamEnabled: boolean, hpReactiveCamStylingEnabled: boolean }`
- Export `PRIVACY_SETTINGS_DEFAULT` with both flags defaulting to `false`
- Export `isValidPrivacySettings(data)` validator
- Export `createPrivacySettings(overrides)` factory
- [ ] 1.2: Write TDD red tests for PlayerPrivacyManager methods
- [ ] 1.3: Create `PlayerPrivacyManager` class with constructor `(adapter)`
- Export `PRIVACY_SETTING_KEYS`, `FEATURE_NAME_MAP`, `validateSettingKey()`, `validateSettingValue()`, `validateFeatureName()`
- [x] 1.2: Write TDD red tests for PlayerPrivacyManager methods
- [x] 1.3: Create `PlayerPrivacyManager` class with constructor `(adapter)`
- Constructor receives FoundryAdapter for user flag access
- No direct `game.*` access (DI enforced)
- [ ] 1.4: Implement `getSettings(userId)` — retrieves privacy settings from user flags
- [x] 1.4: Implement `getSettings(userId)` — retrieves privacy settings from user flags
- Returns merged settings with defaults for missing keys
- Handles null/undefined flag gracefully
- [ ] 1.5: Implement `setSetting(userId, key, value)` — updates a single privacy setting
- [x] 1.5: Implement `setSetting(userId, key, value)` — updates a single privacy setting
- Validates key is known (reactionCamEnabled or hpReactiveCamStylingEnabled)
- Validates value is boolean
- Calls `adapter.users.setFlag(userId, 'video-view-manager', key, value)`
- Emits change event for subscribers
- [ ] 1.6: Implement `isOptedIn(userId, feature)` — convenience method for feature checks
- [x] 1.6: Implement `isOptedIn(userId, feature)` — convenience method for feature checks
- Returns boolean for 'reactionCam' or 'hpReactiveCamStyling'
- Defaults to false if setting not found
- [ ] 1.7: Implement `getAllSettings()` — returns all users' privacy settings (GM only)
- [x] 1.7: Implement `getAllSettings()` — returns all users' privacy settings (GM only)
- Aggregates settings from all connected users
- Only accessible when caller is GM
- [ ] 1.8: Green all PlayerPrivacyManager tests (12-15 tests passing)
- [x] 1.8: Green all PlayerPrivacyManager tests (35 tests passing)
**Acceptance Criteria:** AC-2, AC-3, AC-7, AC-8
@@ -68,6 +68,6 @@ development_status:
# Epic 4: Player Privacy Panel
epic-4: in-progress
4-1-player-privacy-panel-and-automation-opt-ins: ready-for-dev
4-1-player-privacy-panel-and-automation-opt-ins: in-progress
4-2-custom-portrait-fallback: backlog
epic-4-retrospective: optional
@@ -0,0 +1,105 @@
const WebSocket = require('ws');
const ws = new WebSocket('ws://localhost:9222/devtools/page/992C42C102A9604DCB6F7EE5CE6A5048');
console.log('\n========================================');
console.log('Epic 3 Live Tests - Scene Presets');
console.log('========================================\n');
const tests = [
{
id: 1,
name: 'Module is active',
expr: 'game.modules.get("video-view-manager").active'
},
{
id: 2,
name: 'User is GM',
expr: 'game.user.isGM'
},
{
id: 3,
name: 'Current scene exists',
expr: '!!game.scenes.current'
},
{
id: 4,
name: 'Can read scene flags',
expr: '(typeof game.scenes.current.getFlag === "function")'
},
{
id: 5,
name: 'Can write scene flags',
expr: '(typeof game.scenes.current.setFlag === "function")'
},
{
id: 6,
name: 'ui.notifications exists',
expr: '!!ui.notifications'
},
{
id: 7,
name: 'Hooks exists',
expr: '!!Hooks'
},
{
id: 8,
name: 'game.webrtc exists',
expr: 'game.webrtc !== undefined'
}
];
let currentTest = 0;
ws.on('open', () => {
console.log('✓ Connected to Foundry page\n');
runNextTest();
});
function runNextTest() {
if (currentTest >= tests.length) {
console.log('\n========================================');
console.log('✓ All environment tests passed!');
console.log('========================================\n');
console.log('Summary:');
console.log('- FoundryVTT is running with module active');
console.log('- User is GM (required for most Epic 3 features)');
console.log('- Scene flag API is available');
console.log('- Notification system is available');
console.log('- Hooks system is available');
console.log('\n✓ Environment is ready for Epic 3 testing!\n');
ws.close();
return;
}
const test = tests[currentTest];
ws.send(JSON.stringify({
id: test.id,
method: 'Runtime.evaluate',
params: { expression: test.expr }
}));
}
ws.on('message', (data) => {
try {
const response = JSON.parse(data);
const test = tests.find(t => t.id === response.id);
if (test) {
const value = response.result?.result?.value;
const passed = value === true || value === 'function' || value === 'object';
const status = passed ? '✓' : '✗';
console.log(`${status} ${test.name}`);
currentTest++;
runNextTest();
}
} catch (e) {
console.error('Error parsing response:', e.message);
}
});
ws.on('error', (e) => {
console.error('✗ WebSocket error:', e.message);
ws.close();
});
@@ -0,0 +1,379 @@
/**
* Epic 3 Scene Presets - Integration Tests
* Tests the deployed video-view-manager module on localhost:31000
* Run with: npx playwright test _bmad-output/tests/integration/epic-3-scene-presets.spec.js
*
* Note: Chrome DevTools must be running on port 9222 with FoundryVTT
* Browser WSEndpoint: ws://localhost:9222/devtools/browser/1aeaf428-412f-4e20-9f2d-c13533d031ae
*/
import { chromium, expect, test } from '@playwright/test';
// Configuration
const CHROME_DEVTOOLS_URL = 'ws://localhost:9222/devtools/browser/1aeaf428-412f-4e20-9f2d-c13533d031ae';
const FOUNDRY_URL = 'https://localhost:31000/game';
// ============================================================================
// Helper Functions
// ============================================================================
async function waitForModule(page) {
await page.waitForFunction(() => {
const module = game?.modules?.get('video-view-manager');
return module?.active === true;
}, { timeout: 30000 });
}
async function waitForDirectorsBoard(page) {
await page.waitForFunction(() => {
const board = game?.modules?.get('video-view-manager')?._directorsBoard;
return board?.rendered === true;
}, { timeout: 10000 });
}
async function clickByDataAction(page, action) {
await page.evaluate((action) => {
const element = document.querySelector(`[data-action="${action}"]`);
if (element) element.click();
}, action);
}
async function getPresetCount(page) {
return await page.evaluate(() => {
const scene = game.scenes.current;
const presets = scene?.getFlag('video-view-manager', 'presets') || {};
return Object.keys(presets.presets || {}).length;
});
}
async function verifyNotification(page, text) {
await page.waitForFunction((text) => {
const notifications = ui.notifications?.notifications || [];
return notifications.some(n => n.message?.includes(text));
}, { text, timeout: 5000 });
}
async function openDirectorsBoard(page) {
await page.keyboard.down('Control');
await page.keyboard.press('Shift+V');
await page.keyboard.up('Control');
await waitForDirectorsBoard(page);
}
// ============================================================================
// Test Suite: ScenePresetManager Core Functionality
// ============================================================================
test.describe('Epic 3 - ScenePresetManager Core', () => {
let browser;
let page;
test.beforeAll(async () => {
browser = await chromium.connect({
wsEndpoint: CHROME_DEVTOOLS_URL
});
const pages = await browser.pages();
page = pages.find(p => p.url().includes('localhost:31000/game')) || pages[0];
if (!page.url().includes('localhost:31000')) {
await page.goto(FOUNDRY_URL);
}
await waitForModule(page);
});
test.afterAll(async () => {
await browser.close();
});
test('Module is active and ScenePresetManager exists', async () => {
const isActive = await page.evaluate(() => {
const module = game.modules.get('video-view-manager');
return module?.active && module?.scenePresetManager;
});
expect(isActive).toBe(true);
});
test('Save and load preset via DirectorsBoard', async () => {
await openDirectorsBoard(page);
const initialCount = await getPresetCount(page);
await clickByDataAction(page, 'save-preset');
await page.waitForSelector('.dialog [name="presetName"]', { timeout: 5000 });
await page.fill('.dialog [name="presetName"]', 'Epic 3 Test Preset');
await page.click('.dialog button:has-text("Save")');
await page.waitForTimeout(500);
const newCount = await getPresetCount(page);
expect(newCount).toBe(initialCount + 1);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog', { timeout: 5000 });
await page.click('.dialog [data-preset-name="Epic 3 Test Preset"]');
await page.click('.dialog button:has-text("Load")');
await verifyNotification(page, 'applied preset: Epic 3 Test Preset');
});
test('Duplicate preset name shows confirmation dialog', async () => {
await openDirectorsBoard(page);
await clickByDataAction(page, 'save-preset');
await page.waitForSelector('.dialog [name="presetName"]', { timeout: 5000 });
await page.fill('.dialog [name="presetName"]', 'Epic 3 Test Preset');
await page.waitForSelector('.dialog:has-text("already exists")', { timeout: 3000 });
});
});
// ============================================================================
// Test Suite: ConfirmationBar & Auto-Apply
// ============================================================================
test.describe('Epic 3 - ConfirmationBar & Auto-Apply', () => {
let browser;
let page;
test.beforeAll(async () => {
browser = await chromium.connect({
wsEndpoint: CHROME_DEVTOOLS_URL
});
const pages = await browser.pages();
page = pages.find(p => p.url().includes('localhost:31000/game')) || pages[0];
if (!page.url().includes('localhost:31000')) {
await page.goto(FOUNDRY_URL);
}
await waitForModule(page);
});
test.afterAll(async () => {
await browser.close();
});
test('ConfirmationBar appears after preset load', async () => {
await openDirectorsBoard(page);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog', { timeout: 5000 });
await page.click('.dialog [data-preset-name="Epic 3 Test Preset"]');
await page.click('.dialog button:has-text("Load")');
await page.waitForSelector('.sp-confirmation-bar', { timeout: 5000 });
const barText = await page.$eval('.sp-confirmation-bar', el => el.textContent);
expect(barText).toContain('Preset applied');
});
test('ConfirmationBar undo reverts matrix', async () => {
const initialMatrix = await page.evaluate(() => {
const controller = game.modules.get('video-view-manager')?._scryingPoolController;
return JSON.stringify(controller?.visibilityMatrix);
});
await openDirectorsBoard(page);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog', { timeout: 5000 });
await page.click('.dialog [data-preset-name="Epic 3 Test Preset"]');
await page.click('.dialog button:has-text("Load")');
await page.waitForSelector('.sp-confirmation-bar', { timeout: 5000 });
await page.click('.sp-confirmation-bar [data-action="undo"]');
await page.waitForTimeout(500);
const revertedMatrix = await page.evaluate(() => {
const controller = game.modules.get('video-view-manager')?._scryingPoolController;
return JSON.stringify(controller?.visibilityMatrix);
});
expect(revertedMatrix).toBe(initialMatrix);
});
test('ConfirmationBar auto-dismisses after 8 seconds', async () => {
await openDirectorsBoard(page);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog', { timeout: 5000 });
await page.click('.dialog [data-preset-name="Epic 3 Test Preset"]');
await page.click('.dialog button:has-text("Load")');
await page.waitForSelector('.sp-confirmation-bar', { timeout: 5000 });
// Note: This test may be flaky depending on timing
// The bar should auto-dismiss after 8 seconds
await page.waitForTimeout(8500);
const isHidden = await page.evaluate(() => {
const bar = document.querySelector('.sp-confirmation-bar');
return !bar || bar.offsetParent === null;
});
expect(isHidden).toBe(true);
});
});
// ============================================================================
// Test Suite: Import/Export
// ============================================================================
test.describe('Epic 3 - Preset Import/Export', () => {
let browser;
let page;
test.beforeAll(async () => {
browser = await chromium.connect({
wsEndpoint: CHROME_DEVTOOLS_URL
});
const pages = await browser.pages();
page = pages.find(p => p.url().includes('localhost:31000/game')) || pages[0];
if (!page.url().includes('localhost:31000')) {
await page.goto(FOUNDRY_URL);
}
await waitForModule(page);
});
test.afterAll(async () => {
await browser.close();
});
test('Export presets downloads JSON file', async () => {
await openDirectorsBoard(page);
await clickByDataAction(page, 'export-presets');
const download = await page.waitForEvent('download');
const path = await download.path();
const { readFileSync } = await import('fs');
const contents = readFileSync(path, 'utf8');
const data = JSON.parse(contents);
expect(data._version).toBe(1);
expect(data.presets).toBeDefined();
expect(Object.keys(data.presets).length).toBeGreaterThan(0);
});
test('Import with merge adds new presets', async () => {
// First export
await openDirectorsBoard(page);
await clickByDataAction(page, 'export-presets');
const download = await page.waitForEvent('download');
const exportPath = await download.path();
// Delete all presets
await page.evaluate(() => {
const scene = game.scenes.current;
scene.unsetFlag('video-view-manager', 'presets');
});
// Import with merge
await openDirectorsBoard(page);
await clickByDataAction(page, 'import-presets');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(exportPath);
await page.click('text="Merge"');
await page.click('button:has-text("Import")');
await page.waitForTimeout(1000);
const finalCount = await getPresetCount(page);
expect(finalCount).toBeGreaterThan(0);
await verifyNotification(page, 'Imported');
});
test('Import with invalid JSON shows error', async () => {
await openDirectorsBoard(page);
await clickByDataAction(page, 'import-presets');
const { writeFileSync } = await import('fs');
const invalidJsonPath = '/tmp/invalid-presets.json';
writeFileSync(invalidJsonPath, '{invalid json}');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(invalidJsonPath);
await verifyNotification(page, 'Invalid JSON');
});
});
// ============================================================================
// Test Suite: Edge Cases
// ============================================================================
test.describe('Epic 3 - Edge Cases', () => {
let browser;
let page;
test.beforeAll(async () => {
browser = await chromium.connect({
wsEndpoint: CHROME_DEVTOOLS_URL
});
const pages = await browser.pages();
page = pages.find(p => p.url().includes('localhost:31000/game')) || pages[0];
if (!page.url().includes('localhost:31000')) {
await page.goto(FOUNDRY_URL);
}
await waitForModule(page);
});
test.afterAll(async () => {
await browser.close();
});
test('Load preset with no presets shows empty message', async () => {
await page.evaluate(() => {
const scene = game.scenes.current;
scene.unsetFlag('video-view-manager', 'presets');
});
await openDirectorsBoard(page);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog:has-text("No presets saved yet")', { timeout: 5000 });
});
test('Auto-apply respects per-scene disable', async () => {
const hasSetting = await page.evaluate(() => {
const scene = game.scenes.current;
const autoApply = scene.getFlag('video-view-manager', 'autoApply') || {};
return autoApply.enabled !== undefined;
});
expect(hasSetting).toBe(true);
});
test('Socket events fire correctly', async () => {
await page.evaluate(() => {
window.testEvents = [];
Hooks.off('scrying-pool.preset.apply');
Hooks.off('scrying-pool.preset.applied');
});
await page.evaluate(() => {
Hooks.on('scrying-pool.preset.apply', () => window.testEvents.push('apply'));
Hooks.on('scrying-pool.preset.applied', () => window.testEvents.push('applied'));
});
await openDirectorsBoard(page);
await clickByDataAction(page, 'load-preset');
await page.waitForSelector('.dialog', { timeout: 5000 });
await page.click('.dialog [data-preset-name="Epic 3 Test Preset"]');
await page.click('.dialog button:has-text("Load")');
await page.waitForTimeout(1000);
const events = await page.evaluate(() => window.testEvents);
expect(events).toContain('apply');
expect(events).toContain('applied');
});
});
@@ -0,0 +1,99 @@
/**
* Simple Epic 3 connectivity test
* Tests basic module loading and ScenePresetManager availability
*/
import { chromium, expect, test } from '@playwright/test';
const CHROME_DEVTOOLS_URL = 'ws://localhost:9222/devtools/browser/1aeaf428-412f-4e20-9f2d-c13533d031ae';
const FOUNDRY_URL = 'https://localhost:31000/game';
test('Connect to Chrome DevTools and verify Foundry page', async () => {
const browser = await chromium.connect({
wsEndpoint: CHROME_DEVTOOLS_URL
});
const pages = await browser.pages();
console.log('Available pages:', pages.length);
const foundryPage = pages.find(p => p.url().includes('localhost:31000/game'));
expect(foundryPage).toBeDefined();
await foundryPage.goto(FOUNDRY_URL);
// Wait for Foundry to load
await foundryPage.waitForFunction(() => window.game?.ready, { timeout: 30000 });
// Verify module is active
const isActive = await foundryPage.evaluate(() => {
const module = game.modules.get('video-view-manager');
return module?.active;
});
expect(isActive).toBe(true);
console.log('✓ Module is active');
// Verify ScenePresetManager exists
const hasPresetManager = await foundryPage.evaluate(() => {
const module = game.modules.get('video-view-manager');
return module?.scenePresetManager !== undefined;
});
expect(hasPresetManager).toBe(true);
console.log('✓ ScenePresetManager exists');
// Verify DirectorsBoard exists
const hasDirectorsBoard = await foundryPage.evaluate(() => {
const module = game.modules.get('video-view-manager');
return module?._directorsBoard !== undefined;
});
expect(hasDirectorsBoard).toBe(true);
console.log('✓ DirectorsBoard exists');
await browser.close();
});
test('Verify ConfirmationBar exists', async () => {
const browser = await chromium.connect({
wsEndpoint: CHROME_DEVTOOLS_URL
});
const pages = await browser.pages();
const foundryPage = pages.find(p => p.url().includes('localhost:31000/game')) || pages[0];
await foundryPage.goto(FOUNDRY_URL);
await foundryPage.waitForFunction(() => window.game?.ready, { timeout: 30000 });
const hasConfirmationBar = await foundryPage.evaluate(() => {
const module = game.modules.get('video-view-manager');
return module?._confirmationBar !== undefined;
});
expect(hasConfirmationBar).toBe(true);
console.log('✓ ConfirmationBar exists');
await browser.close();
});
test('Verify PresetImportExportManager exists', async () => {
const browser = await chromium.connect({
wsEndpoint: CHROME_DEVTOOLS_URL
});
const pages = await browser.pages();
const foundryPage = pages.find(p => p.url().includes('localhost:31000/game')) || pages[0];
await foundryPage.goto(FOUNDRY_URL);
await foundryPage.waitForFunction(() => window.game?.ready, { timeout: 30000 });
const hasImportExportManager = await foundryPage.evaluate(() => {
const module = game.modules.get('video-view-manager');
return module?.presetImportExportManager !== undefined;
});
expect(hasImportExportManager).toBe(true);
console.log('✓ PresetImportExportManager exists');
await browser.close();
});