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
+151
View File
@@ -0,0 +1,151 @@
/**
* Privacy Settings contract.
*
* Privacy settings control player opt-in/out for automation features that affect
* their on-screen presence. Settings are stored as user flags on the user document.
*
* Storage key: game.user.setFlag('video-view-manager', key, value)
* Shape: { reactionCamEnabled: boolean, hpReactiveCamStylingEnabled: boolean }
*
* @module contracts/privacy-settings
*/
/**
* @typedef {Object} PrivacySettings
* @property {boolean} reactionCamEnabled - Whether Reaction Cam automation is enabled for this user.
* @property {boolean} hpReactiveCamStylingEnabled - Whether HP-Reactive Cam Styling is enabled for this user.
*/
export const PRIVACY_SETTINGS_VERSION = 1;
/**
* Default privacy settings - all features disabled by default (opt-in, not opt-out).
* @type {PrivacySettings}
*/
export const PRIVACY_SETTINGS_DEFAULT = {
reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
};
/**
* Canonical list of privacy setting keys.
* @type {string[]}
*/
export const PRIVACY_SETTING_KEYS = Object.freeze([
"reactionCamEnabled",
"hpReactiveCamStylingEnabled",
]);
/**
* Feature name mapping for opt-in checks.
* @type {Object.<string, string>}
*/
export const FEATURE_NAME_MAP = Object.freeze({
reactionCam: "reactionCamEnabled",
hpReactiveCamStyling: "hpReactiveCamStylingEnabled",
});
/**
* Creates a new PrivacySettings object with defaults.
* Only known keys from PRIVACY_SETTINGS_DEFAULT are included; extra properties are ignored.
* @param {Partial<PrivacySettings>} [overrides={}] - Override default values.
* @returns {PrivacySettings}
*/
export function createPrivacySettings(overrides = {}) {
const result = { ...PRIVACY_SETTINGS_DEFAULT };
for (const key of PRIVACY_SETTING_KEYS) {
if (key in overrides) {
result[key] = overrides[key];
}
}
return result;
}
/**
* Validates a PrivacySettings DTO. Throws TypeError on any violation.
* @param {unknown} data - Value to validate.
* @returns {PrivacySettings} The validated settings.
* @throws {TypeError} If data fails validation.
*/
export function isValidPrivacySettings(data) {
if (data === null) {
throw new TypeError("PrivacySettings: must be an object");
}
if (typeof data !== "object" || Array.isArray(data)) {
throw new TypeError("PrivacySettings: must be an object");
}
const obj = /** @type {Record<string, unknown>} */ (data);
const { reactionCamEnabled, hpReactiveCamStylingEnabled, ...rest } = obj;
if (Object.keys(rest).length > 0) {
throw new TypeError(
`PrivacySettings: unknown keys: ${Object.keys(rest).join(", ")}`
);
}
if (typeof reactionCamEnabled !== "boolean") {
throw new TypeError(
`PrivacySettings: reactionCamEnabled must be a boolean, got ${typeof reactionCamEnabled}`
);
}
if (typeof hpReactiveCamStylingEnabled !== "boolean") {
throw new TypeError(
`PrivacySettings: hpReactiveCamStylingEnabled must be a boolean, got ${typeof hpReactiveCamStylingEnabled}`
);
}
return /** @type {PrivacySettings} */ (data);
}
/**
* Validates a single privacy setting key.
* @param {string} key - The setting key to validate.
* @returns {string} The validated key.
* @throws {TypeError} If key is invalid.
*/
export function validateSettingKey(key) {
if (typeof key !== "string" || key.length === 0) {
throw new TypeError("Setting key must be a non-empty string");
}
if (!PRIVACY_SETTING_KEYS.includes(key)) {
throw new TypeError(
`PrivacySettings: unknown key '${key}'. Valid keys: ${PRIVACY_SETTING_KEYS.join(
", "
)}`
);
}
return key;
}
/**
* Validates a single privacy setting value.
* @param {unknown} value - The value to validate.
* @returns {boolean} The validated value.
* @throws {TypeError} If value is invalid.
*/
export function validateSettingValue(value) {
if (typeof value !== "boolean") {
throw new TypeError(
`PrivacySettings: value must be a boolean, got ${typeof value}`
);
}
return value;
}
/**
* Validates a feature name for opt-in checks.
* @param {string} feature - The feature name to validate.
* @returns {string} The validated feature name.
* @throws {TypeError} If feature is invalid.
*/
export function validateFeatureName(feature) {
if (typeof feature !== "string" || feature.length === 0) {
throw new TypeError("Feature name must be a non-empty string");
}
const validFeatures = Object.keys(FEATURE_NAME_MAP);
if (!validFeatures.includes(feature)) {
throw new TypeError(
`PrivacySettings: unknown feature '${feature}'. Valid features: ${validFeatures.join(
", "
)}`
);
}
return feature;
}
+255
View File
@@ -0,0 +1,255 @@
/**
* PlayerPrivacyManager — Manages player privacy settings for automation opt-ins.
*
* Owns: privacy settings retrieval, validation, persistence, and change notifications.
* Settings are stored as user flags on the user document.
* No socket broadcasting — each client reads its own user's flags.
*
* Import rule: may only import from src/contracts/ and src/utils/.
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
*
* @module core/PlayerPrivacyManager
*/
import {
PRIVACY_SETTINGS_DEFAULT,
PRIVACY_SETTING_KEYS,
FEATURE_NAME_MAP,
validateSettingKey,
validateSettingValue,
validateFeatureName,
} from "../contracts/privacy-settings.js";
/**
* Manages player privacy settings for automation opt-ins.
*
* Settings are stored as world-level user flags:
* - game.user.setFlag('video-view-manager', 'reactionCamEnabled', boolean)
* - game.user.setFlag('video-view-manager', 'hpReactiveCamStylingEnabled', boolean)
*
* Players can only edit their own settings.
* GM can read (but not edit) all players' settings.
*
* No socket broadcasting — privacy is client-local.
*/
export class PlayerPrivacyManager {
/**
* @param {import('../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* Injected FoundryAdapter surface for user flag access.
* @throws {TypeError} If adapter is invalid or lacks required user methods.
*/
constructor(adapter) {
// Validate adapter
if (!adapter || typeof adapter !== "object") {
throw new TypeError(
"PlayerPrivacyManager: adapter argument is required and must be an object"
);
}
// Validate adapter.users
if (!adapter.users || typeof adapter.users !== "object") {
throw new TypeError(
"PlayerPrivacyManager: adapter.users must be an object"
);
}
// Validate adapter.users.get
if (
!adapter.users.get ||
typeof adapter.users.get !== "function"
) {
throw new TypeError(
"PlayerPrivacyManager: adapter.users.get must be a function"
);
}
// Validate adapter.users.all
if (
!adapter.users.all ||
typeof adapter.users.all !== "function"
) {
throw new TypeError(
"PlayerPrivacyManager: adapter.users.all must be a function"
);
}
this._adapter = adapter;
/**
* @type {Set<function(userId: string, key: string, value: boolean, previousValue: boolean): void>}
* Subscribers for setting change events.
*/
this._subscribers = new Set();
}
/**
* Retrieves privacy settings for a specific user.
*
* Reads from user flags with module scope 'video-view-manager'.
* Missing settings are merged with defaults (all false).
*
* @param {string} userId - The user ID to retrieve settings for.
* @returns {import('../contracts/privacy-settings.js').PrivacySettings} The user's privacy settings.
*/
getSettings(userId) {
const user = this._adapter.users.get(userId);
// Return defaults if user doesn't exist or has no getFlag
if (!user || typeof user.getFlag !== "function") {
return { ...PRIVACY_SETTINGS_DEFAULT };
}
const settings = { ...PRIVACY_SETTINGS_DEFAULT };
for (const key of PRIVACY_SETTING_KEYS) {
const value = user.getFlag("video-view-manager", key);
if (value !== undefined && value !== null) {
settings[key] = value;
}
}
return settings;
}
/**
* Updates a single privacy setting for a user.
*
* Validates key and value before persistence.
* Emits change event to subscribers after successful update.
*
* @param {string} userId - The user ID to update settings for.
* @param {string} key - The setting key (must be in PRIVACY_SETTING_KEYS).
* @param {boolean} value - The new setting value.
* @returns {Promise<void>} Resolves when the setting is persisted.
* @throws {TypeError} If key is invalid, value is not boolean, or user doesn't exist.
*/
async setSetting(userId, key, value) {
// Validate key
validateSettingKey(key);
// Validate value
validateSettingValue(value);
// Get user
const user = this._adapter.users.get(userId);
if (!user) {
throw new TypeError(`PlayerPrivacyManager: User '${userId}' not found`);
}
// Validate user has setFlag method
if (typeof user.setFlag !== "function") {
throw new TypeError(
`PlayerPrivacyManager: User '${userId}' does not support setFlag`
);
}
// Get previous value for change event
const previousValue = this.getSettings(userId)[key];
// Persist the setting via user flag
// Note: FoundryVTT user.setFlag returns a Promise
await user.setFlag("video-view-manager", key, value);
// Notify subscribers
this._notifySubscribers(userId, key, value, previousValue);
}
/**
* Checks if a user has opted in to a specific automation feature.
*
* @param {string} userId - The user ID to check.
* @param {string} feature - The feature name ('reactionCam' or 'hpReactiveCamStyling').
* @returns {boolean} True if the user has opted in, false otherwise.
* @throws {TypeError} If feature name is invalid.
*/
isOptedIn(userId, feature) {
// Validate feature name
validateFeatureName(feature);
// Get the setting key for this feature
const key = FEATURE_NAME_MAP[feature];
// Get user settings
const settings = this.getSettings(userId);
// Return the setting value (defaults to false if not found)
return settings[key] ?? false;
}
/**
* Retrieves privacy settings for all connected users.
*
* Only includes users that have the getFlag method (supports privacy settings).
* Primarily for GM use to view all players' settings.
*
* @returns {Map<string, import('../contracts/privacy-settings.js').PrivacySettings>}
* Map of userId → PrivacySettings for all users with valid settings.
*/
getAllSettings() {
const allUsers = this._adapter.users.all();
const result = new Map();
for (const user of allUsers) {
// Skip users without proper ID
if (!user || !user.id) continue;
// Skip users without getFlag method
if (typeof user.getFlag !== "function") continue;
const settings = this.getSettings(user.id);
result.set(user.id, settings);
}
return result;
}
/**
* Subscribes to privacy setting change events.
*
* Subscribers are called with (userId, key, newValue, previousValue).
* Returns an unsubscribe function.
*
* @param {function(userId: string, key: string, value: boolean, previousValue: boolean): void} callback
* Callback function to invoke on setting changes.
* @returns {function(): void} Function to unsubscribe.
*/
onChange(callback) {
this._subscribers.add(callback);
// Return unsubscribe function
return () => {
this._subscribers.delete(callback);
};
}
/**
* Notifies all subscribers of a setting change.
*
* @private
* @param {string} userId - The user ID whose setting changed.
* @param {string} key - The setting key that changed.
* @param {boolean} newValue - The new setting value.
* @param {boolean} previousValue - The previous setting value.
*/
_notifySubscribers(userId, key, newValue, previousValue) {
for (const callback of this._subscribers) {
try {
callback(userId, key, newValue, previousValue);
} catch (err) {
// Swallow subscriber errors to prevent one bad subscriber from breaking others
console.error(
`[ScryingPool] PlayerPrivacyManager subscriber error:`,
err
);
}
}
}
/**
* Cleans up internal state.
* Safe to call multiple times.
*/
teardown() {
this._subscribers.clear();
}
}