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;
}