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:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user