Story 4.2 completed

This commit is contained in:
2026-05-24 00:37:21 +02:00
parent de1b33c453
commit 56eeb7cc83
21 changed files with 3836 additions and 56 deletions
+111 -13
View File
@@ -5,15 +5,34 @@
* 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 }
* Shape: { reactionCamEnabled: boolean, hpReactiveCamStylingEnabled: boolean, customPortraitFallback: string | null }
*
* @module contracts/privacy-settings
*/
/**
* Maximum portrait file size in bytes (5MB).
* Note: FoundryVTT user flags typically have a ~50KB limit, so images should be optimized.
* @type {number}
*/
export const MAX_PORTRAIT_SIZE = 5 * 1024 * 1024;
/**
* Supported portrait image MIME types.
* @type {string[]}
*/
export const VALID_PORTRAIT_FORMATS = Object.freeze([
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
]);
/**
* @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.
* @property {string|null} customPortraitFallback - DataURL string for custom portrait fallback image, or null if not set.
*/
export const PRIVACY_SETTINGS_VERSION = 1;
@@ -25,6 +44,7 @@ export const PRIVACY_SETTINGS_VERSION = 1;
export const PRIVACY_SETTINGS_DEFAULT = {
reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: null,
};
/**
@@ -34,6 +54,7 @@ export const PRIVACY_SETTINGS_DEFAULT = {
export const PRIVACY_SETTING_KEYS = Object.freeze([
"reactionCamEnabled",
"hpReactiveCamStylingEnabled",
"customPortraitFallback",
]);
/**
@@ -45,6 +66,55 @@ export const FEATURE_NAME_MAP = Object.freeze({
hpReactiveCamStyling: "hpReactiveCamStylingEnabled",
});
/**
* Validates a DataURL for portrait images.
* Accepts DataURLs with supported MIME types or null/undefined/empty string.
* @param {unknown} dataURL - The DataURL string to validate.
* @returns {string|null|undefined} The validated DataURL (or null/undefined if valid).
* @throws {TypeError} If the DataURL format is invalid or uses unsupported MIME type.
*/
export function validatePortraitDataURL(dataURL) {
// Accept null, undefined, or empty string as valid (no custom portrait)
if (dataURL === null || dataURL === undefined) {
return dataURL;
}
if (typeof dataURL !== "string") {
throw new TypeError(`Invalid DataURL: expected string, got ${typeof dataURL}`);
}
if (dataURL === "") {
return dataURL;
}
// Validate DataURL format: must start with "data:"
if (!dataURL.startsWith("data:")) {
throw new TypeError("Invalid DataURL format: must start with 'data:'");
}
// Extract MIME type from DataURL (format: data:<mediatype>;base64,... or data:<mediatype>,...)
// Match image MIME type after data: (captures the part before ; or ,)
const mimeMatch = dataURL.match(/^data:(image\/[a-zA-Z0-9+\-.]+)/);
if (!mimeMatch) {
throw new TypeError("Invalid DataURL format: missing or invalid MIME type");
}
const mimeType = mimeMatch[1].toLowerCase();
// Validate against supported formats
if (!VALID_PORTRAIT_FORMATS.includes(mimeType)) {
throw new TypeError(
`Unsupported portrait format: ${mimeType}. Supported: ${VALID_PORTRAIT_FORMATS.join(", ")}`
);
}
// Verify actual data content exists after MIME header
const dataPart = dataURL.substring(dataURL.indexOf(",") + 1);
if (!dataPart || dataPart.length === 0) {
throw new TypeError("Invalid DataURL: empty image data");
}
return dataURL;
}
/**
* Creates a new PrivacySettings object with defaults.
* Only known keys from PRIVACY_SETTINGS_DEFAULT are included; extra properties are ignored.
@@ -63,6 +133,7 @@ export function createPrivacySettings(overrides = {}) {
/**
* Validates a PrivacySettings DTO. Throws TypeError on any violation.
* Backward compatible: accepts settings without customPortraitFallback key.
* @param {unknown} data - Value to validate.
* @returns {PrivacySettings} The validated settings.
* @throws {TypeError} If data fails validation.
@@ -75,23 +146,50 @@ export function isValidPrivacySettings(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) {
// Check for unknown keys (keys not in PRIVACY_SETTING_KEYS)
const knownKeys = new Set(PRIVACY_SETTING_KEYS);
const unknownKeys = Object.keys(obj).filter((k) => !knownKeys.has(k));
if (unknownKeys.length > 0) {
throw new TypeError(
`PrivacySettings: unknown keys: ${Object.keys(rest).join(", ")}`
`PrivacySettings: unknown keys: ${unknownKeys.join(", ")}`
);
}
if (typeof reactionCamEnabled !== "boolean") {
throw new TypeError(
`PrivacySettings: reactionCamEnabled must be a boolean, got ${typeof reactionCamEnabled}`
);
// Validate each known key if present
if ("reactionCamEnabled" in obj) {
if (typeof obj.reactionCamEnabled !== "boolean") {
throw new TypeError(
`PrivacySettings: reactionCamEnabled must be a boolean, got ${typeof obj.reactionCamEnabled}`
);
}
}
if (typeof hpReactiveCamStylingEnabled !== "boolean") {
throw new TypeError(
`PrivacySettings: hpReactiveCamStylingEnabled must be a boolean, got ${typeof hpReactiveCamStylingEnabled}`
);
if ("hpReactiveCamStylingEnabled" in obj) {
if (typeof obj.hpReactiveCamStylingEnabled !== "boolean") {
throw new TypeError(
`PrivacySettings: hpReactiveCamStylingEnabled must be a boolean, got ${typeof obj.hpReactiveCamStylingEnabled}`
);
}
}
return /** @type {PrivacySettings} */ (data);
if ("customPortraitFallback" in obj) {
if (obj.customPortraitFallback !== null && typeof obj.customPortraitFallback !== "string") {
throw new TypeError(
`PrivacySettings: customPortraitFallback must be a string or null, got ${typeof obj.customPortraitFallback}`
);
}
// If it's a string, validate it's a valid DataURL
if (typeof obj.customPortraitFallback === "string") {
try {
validatePortraitDataURL(obj.customPortraitFallback);
} catch (e) {
throw new TypeError(
`PrivacySettings: customPortraitFallback ${e.message}`
);
}
}
}
return /** @type {PrivacySettings} */ (obj);
}
/**