bondage-college-mirr/BondageClub/Scripts/Validation.js
Jean-Baptiste Emmanuel Zorg 18e736d1c6 Use a PRNG to build the lock pin combination
As we were saving the lockpick combination onto the item, but not
sending the update, a concurrent update from the lockpicked character
(which wouldn't have had the property set as it wouldn't have opened the
lockpicking UI) caused a reset of the lockpicking combination,
apparently randomly.

Fix the bug by building the lockpicking combination using the PRNG, 
seeded with a combination of the lockpicked character's ID, the group
being lockpicked, and the difficulty of the lock, which itself is a
depends on the lockpicker's state (skill and blocking, mostly).

This allows removing the validation handling for that property, and
makes the lock-pick combination unique per lockpicker.
2022-04-22 22:56:39 +02:00

1010 lines
44 KiB
JavaScript

"use strict";
// Regexes for lock combination numbers and passwords
const ValidationCombinationNumberRegex = /^\d{4}$/;
const ValidationPasswordRegex = /^[A-Z]{1,8}$/;
const ValidationDefaultCombinationNumber = "0000";
const ValidationDefaultPassword = "UNLOCK";
const ValidationRemoveTimerToleranceMs = 5000;
const ValidationNonModifiableLockProperties = ["LockedBy", "LockMemberNumber"];
const ValidationRestrictedLockProperties = [
"EnableRandomInput", "RemoveItem", "ShowTimer", "CombinationNumber", "Password", "Hint", "LockSet",
];
const ValidationTimerLockProperties = ["MemberNumberList", "RemoveTimer"];
const ValidationAllLockProperties = ValidationNonModifiableLockProperties
.concat(ValidationRestrictedLockProperties)
.concat(ValidationTimerLockProperties)
.concat(["MemberNumberListKeys"]);
const ValidationModifiableProperties = ValidationAllLockProperties.concat(["Effect", "Expression"]);
/**
* Creates the appearance update parameters used to validate an appearance diff, based on the provided target character
* and the source character's member number.
* @param {Character} C - The target character (to whom the appearance update is being applied)
* @param {number} sourceMemberNumber - The member number of the source player (the person that sent the update)
* @returns {AppearanceUpdateParameters} - Appearance update parameters used based on the relationship between the
* target and source characters
*/
function ValidationCreateDiffParams(C, sourceMemberNumber) {
const fromSelf = sourceMemberNumber === C.MemberNumber;
const fromOwner = C.Ownership != null && (sourceMemberNumber === C.Ownership.MemberNumber || fromSelf);
const loverNumbers = CharacterGetLoversNumbers(C);
let fromLover = loverNumbers.includes(sourceMemberNumber) || fromSelf;
// An update from the player's owner is counted as being from a lover if lover locks aren't blocked by a lover rule
if (fromOwner && !fromLover) {
let ownerCanUseLoverLocks = true;
if (C.ID === 0 && LogQuery("BlockLoverLockOwner", "LoverRule")) {
ownerCanUseLoverLocks = false;
}
fromLover = ownerCanUseLoverLocks;
}
return { C, fromSelf, fromOwner, fromLover, sourceMemberNumber };
}
/**
* Resolves an appearance diff based on the previous item, new item, and the appearance update parameters provided.
* Returns an {@link ItemDiffResolution} object containing the final appearance item and a valid flag indicating
* whether or not the new item had to be modified/rolled back.
* @param {Item|null} previousItem - The previous item that the target character had equipped (or null if none)
* @param {Item|null} newItem - The new item to equip (may be identical to the previous item, or null if removing)
* @param {AppearanceUpdateParameters} params - The appearance update parameters that apply to the diff
* @returns {ItemDiffResolution} - The diff resolution - a wrapper object containing the final item and a flag
* indicating whether or not the change was valid.
*/
function ValidationResolveAppearanceDiff(previousItem, newItem, params) {
let result;
if (!previousItem && !newItem) {
result = { item: previousItem, valid: true };
} else if (!previousItem) {
result = ValidationResolveAddDiff(newItem, params);
} else if (!newItem) {
result = ValidationResolveRemoveDiff(previousItem, params);
} else if (previousItem.Asset === newItem.Asset) {
result = ValidationResolveModifyDiff(previousItem, newItem, params);
} else {
result = ValidationResolveSwapDiff(previousItem, newItem, params);
}
let { item, valid } = result;
// If the diff has resolved to an item, sanitize its properties
if (item) valid = !ValidationSanitizeProperties(params.C, item) && valid;
return { item, valid };
}
/**
* Resolves an appearance diff where an item is being added (i.e. there was no previous item in the asset group). Add
* diffs are handled as the composite of two operations: item addition, followed by property modification. First we
* check whether the base item can be added, and then we check that any added properties are permitted.
* @param {Item} newItem - The new item to equip
* @param {AppearanceUpdateParameters} params - The appearance update parameters that apply to the diff
* @returns {ItemDiffResolution} - The diff resolution - a wrapper object containing the final item and a flag
* indicating whether or not the change was valid.
*/
function ValidationResolveAddDiff(newItem, params) {
const canAdd = ValidationCanAddItem(newItem, params);
if (!canAdd) {
console.warn(`Invalid addition of ${ValidationItemWarningMessage(newItem, params)}`);
return { item: null, valid: false };
}
const itemWithoutProperties = {
Asset: newItem.Asset,
Difficulty: newItem.Difficulty,
Color: newItem.Color,
};
return ValidationResolveModifyDiff(itemWithoutProperties, newItem, params);
}
/**
* Resolves an appearance diff where an item is being removed (i.e. there was previously an item in the asset group, but
* it is being removed)
* @param {Item} previousItem - The previous item to remove
* @param {AppearanceUpdateParameters} params - The appearance update parameters that apply to the diff
* @param {boolean} [isSwap] - Whether or not the removal is part of an item swap operation. This will allow certain
* items which cannot normally be removed (e.g. items with `AllowNone: false`) to be removed
* @returns {ItemDiffResolution} - The diff resolution - a wrapper object containing the final item and a flag
* indicating whether or not the change was valid.
*/
function ValidationResolveRemoveDiff(previousItem, params, isSwap) {
const canRemove = ValidationCanRemoveItem(previousItem, params, isSwap);
if (!canRemove) {
console.warn(`Invalid removal of ${ValidationItemWarningMessage(previousItem, params)}`);
}
return {
item: canRemove ? null : previousItem,
valid: canRemove,
};
}
/**
* Resolves an appearance diff where an item is being swapped (i.e. there was an item previously in the asset group, but
* the new item uses a different asset to the previous item). Swap diffs are handled as the composite of three
* operations: item removal, item addition, and then property modification. First we check whether the previous item
* can be removed, then whether the new item can be added, and finally we check that any added properties are permitted.
* @param {Item} previousItem - The previous item to remove
* @param {Item} newItem - The new item to add
* @param {AppearanceUpdateParameters} params - The appearance update parameters that apply to the diff
* @returns {ItemDiffResolution} - The diff resolution - a wrapper object containing the final item and a flag
* indicating whether or not the change was valid.
*/
function ValidationResolveSwapDiff(previousItem, newItem, params) {
// First, attempt to remove the previous item
let result = ValidationResolveRemoveDiff(previousItem, params, true);
// If the removal result was valid, attempt to add the new item
if (result.valid) result = ValidationResolveAddDiff(newItem, params);
// If the result is valid, return it
if (result.valid) return result;
// Otherwise, return the previous item and an invalid status
else return { item: previousItem, valid: false };
}
/**
* Resolves an appearance diff where an item is being modified (i.e. there was an item previously in the asset group,
* and the new item uses the same asset as the previous item). The function primarily validates modifications to locked
* items
* @param {Item} previousItem - The previous item to remove
* @param {Item} newItem - The new item to add
* @param {AppearanceUpdateParameters} params - The appearance update parameters that apply to the diff
* @returns {ItemDiffResolution} - The diff resolution - a wrapper object containing the final item and a flag
* indicating whether or not the change was valid.
*/
function ValidationResolveModifyDiff(previousItem, newItem, params) {
const { C, fromSelf, sourceMemberNumber } = params;
// If the update is coming from ourself, it's always permitted
if (fromSelf) return { item: newItem, valid: true };
const asset = previousItem.Asset;
const group = asset.Group;
const previousProperty = previousItem.Property || {};
/** @type {ItemProperties} */
const newProperty = newItem.Property = newItem.Property || {};
const itemBlocked = ValidationIsItemBlockedOrLimited(C, sourceMemberNumber, group.Name, asset.Name) ||
ValidationIsItemBlockedOrLimited(C, sourceMemberNumber, group.Name, asset.Name, newProperty.Type);
// If the type has changed and the new type is blocked/limited for the target character, prevent modifications
if (newProperty.Type !== previousProperty.Type && itemBlocked) {
return { item: previousItem, valid: false };
}
let valid = ValidationResolveLockModification(previousItem, newItem, params, itemBlocked);
// If the source wouldn't usually be able to add the item, ensure that some properties are not modified
if (!ValidationCanAddItem(newItem, params)) {
const warningSuffix = ValidationItemWarningMessage(previousItem, params);
// Block changing the color of non-clothing appearance items/cosplay items if the target does not permit that
if (!CommonColorsEqual(newItem.Color, previousItem.Color)) {
console.warn(`Invalid modification of color for item ${warningSuffix}`);
newItem.Color = previousItem.Color;
valid = false;
}
// Block changing the base difficulty of non-clothing appearance items/cosplay items
if (newItem.Difficulty !== previousItem.Difficulty) {
console.warn(`Invalid modification of difficulty for item ${warningSuffix}`);
newItem.Difficulty = previousItem.Difficulty;
valid = false;
}
// Block changing properties, but exclude modifiable and lock-related properties, as they get handled separately
const previousKeys = Object.keys(previousProperty)
.filter(key => !ValidationModifiableProperties.includes(key));
const newKeys = Object.keys(newProperty).filter(key => !ValidationModifiableProperties.includes(key));
previousKeys.forEach(key => {
valid = !ValidationCopyProperty(previousProperty, newProperty, key) && valid;
});
newKeys.forEach((key) => {
if (!previousKeys.includes(key)) {
console.warn(`Invalid modification of property "${key}" for item ${warningSuffix}`);
valid = false;
delete newProperty[key];
}
});
}
if (!Object.keys(newProperty).length) delete newItem.Property;
return { item: newItem, valid };
}
/**
* Resolves modifications to an item's lock properties and returns a boolean to indicate whether or not the
* modifications were valid.
* @param {Item} previousItem - The previous item to remove
* @param {Item} newItem - The new item to add
* @param {AppearanceUpdateParameters} params - The appearance update parameters that apply to the diff
* @param {boolean} itemBlocked - Whether or not the item is blocked or limited for the source player
* @returns {boolean} - true if the lock modifications (if any) were valid, false otherwise
*/
function ValidationResolveLockModification(previousItem, newItem, params, itemBlocked) {
const { C, sourceMemberNumber } = params;
const previousProperty = previousItem.Property || {};
const newProperty = newItem.Property = newItem.Property || {};
const previousLock = InventoryGetLock(previousItem);
const newLock = InventoryGetLock(newItem);
const notLocked = !previousLock && !newLock;
if (notLocked || !ValidationLockWasModified(previousProperty, newProperty)) {
return true;
}
const lockSwapped = !!newLock && !!previousLock && newLock.Asset.Name !== previousLock.Asset.Name;
const lockModified = !!newLock && !!previousLock && !lockSwapped;
const lockRemoved = lockSwapped || (!newLock && !!previousLock);
const lockAdded = lockSwapped || (!!newLock && !previousLock);
const newLockBlocked = !!newLock && ValidationIsItemBlockedOrLimited(
C, sourceMemberNumber, newLock.Asset.Group.Name, newLock.Asset.Name,
);
const lockChangeInvalid = (lockRemoved && !ValidationIsLockChangePermitted(previousLock, params, true)) ||
(lockAdded && !ValidationIsLockChangePermitted(newLock, params)) ||
((lockAdded || lockModified || lockSwapped) && (newLockBlocked || itemBlocked));
if (lockChangeInvalid) {
if (previousLock) {
// If there was a lock previously, reapply the old lock
if (lockRemoved) {
console.warn(`Invalid removal of lock ${ValidationItemWarningMessage(previousLock, params)}`);
} else if (lockSwapped) {
console.warn(`Invalid addition of lock ${ValidationItemWarningMessage(newLock, params)}`);
} else {
console.warn(`Invalid modification of lock ${ValidationItemWarningMessage(newLock, params)}`);
}
InventoryLock(C, newItem, previousLock, previousProperty.LockMemberNumber, false);
ValidationCloneLock(previousProperty, newProperty);
return false;
} else {
// Otherwise, delete any lock
console.warn(`Invalid addition of lock ${ValidationItemWarningMessage(newLock, params)}`);
return !ValidationDeleteLock(newItem.Property);
}
} else if (lockModified) {
// If the lock has been modified, then ensure lock properties don't change (except where they should be able to)
const hasLockPermissions = ValidationIsLockChangePermitted(previousLock, params) && !newLockBlocked;
return !ValidationRollbackInvalidLockProperties(previousProperty, newProperty, hasLockPermissions);
}
// If there are no other issues, the change is valid
return true;
}
/**
* Determines whether or not a lock was modified on an item from its previous and new property values
* @param previousProperty - The previous item property
* @param newProperty - The new item property
* @returns {boolean} - true if the item's lock was modified (added/removed/swapped/modified), false otherwise
*/
function ValidationLockWasModified(previousProperty, newProperty) {
return previousProperty.LockedBy !== newProperty.LockedBy ||
ValidationAllLockProperties.some((key) => !CommonDeepEqual(previousProperty[key], newProperty[key]));
}
/**
* Returns a commonly used warning message indicating that an invalid change to an item was made, along with the target
* and source characters' member numbers.
* @param {Item} item - The item being modified
* @param {AppearanceUpdateParameters} params - The appearance update parameters that apply to the diff
* @returns {string} - The warning message
*/
function ValidationItemWarningMessage(item, { C, sourceMemberNumber }) {
return `${item.Asset.Name} on member number ${C.IsNpc() ? C.Name : C.MemberNumber} by member number ${sourceMemberNumber || Player.MemberNumber} blocked`;
}
/**
* Determines whether or not a lock can be modified based on the lock object and the provided appearance update
* parameters.
* @param {Item} lock - The lock object that is being checked, as returned by {@link InventoryGetLock}
* @param {AppearanceUpdateParameters} params - The appearance update parameters that apply to the diff
* @param {boolean} [remove] - Whether the lock change is a removal
* @returns {boolean} - TRUE if the lock can be modified, FALSE otherwise
*/
function ValidationIsLockChangePermitted(lock, { C, fromOwner, fromLover }, remove = false) {
if (!lock) return true;
if (lock.Asset.OwnerOnly && !fromOwner) return false;
if (lock.Asset.LoverOnly) {
// Owners can always remove lover locks, regardless of lover rules
if (remove && fromOwner) return true;
else return fromLover;
}
return true;
}
/**
* Copies an item's lock-related properties from one Property object to another based on whether or not the source
* character has permissions to modify the lock. Rolls back any invalid changes to their previous values.
* @param {object} sourceProperty - The original Property object on the item
* @param {object} targetProperty - The Property object on the modified item
* @param {boolean} hasLockPermissions - Whether or not the source character of the appearance change has permission to
* modify the lock (as determined by {@link ValidationIsLockChangePermitted})
* @returns {boolean} - TRUE if the target Property object was modified as a result of copying (indicating that there
* were invalid changes to the lock), FALSE otherwise
*/
function ValidationRollbackInvalidLockProperties(sourceProperty, targetProperty, hasLockPermissions) {
let changed = false;
for (const key of ValidationNonModifiableLockProperties) {
changed = ValidationCopyProperty(sourceProperty, targetProperty, key) || changed;
}
if (!hasLockPermissions) {
for (const key of ValidationRestrictedLockProperties) {
changed = ValidationCopyProperty(sourceProperty, targetProperty, key) || changed;
}
if (!targetProperty.EnableRandomInput) {
for (const key of ValidationTimerLockProperties) {
changed = ValidationCopyProperty(sourceProperty, targetProperty, key) || changed;
}
}
}
return changed;
}
/**
* Clones all lock properties from one Property object to another.
* @param {object} sourceProperty - The property object to clone properties from
* @param {object} targetProperty - The property object to clone properties to
* @returns {void} - Nothing
*/
function ValidationCloneLock(sourceProperty, targetProperty) {
for (const key of ValidationAllLockProperties) {
targetProperty[key] = sourceProperty[key];
}
}
/**
* Copies the value of a single property key from a source Property object to a target Property object.
* @param {object} sourceProperty - The original Property object on the item
* @param {object} targetProperty - The Property object on the modified item
* @param {string} key - The property key whose value to copy
* @returns {boolean} - TRUE if the target Property object was modified as a result of copying (indicating that there
* were invalid changes to the property), FALSE otherwise
*/
function ValidationCopyProperty(sourceProperty, targetProperty, key) {
if (sourceProperty[key] != null && !CommonDeepEqual(targetProperty[key], sourceProperty[key])) {
targetProperty[key] = sourceProperty[key];
return true;
}
return false;
}
/**
* Determines whether an item can be added to the target character, based on the provided appearance update parameters.
* Note that the item's properties are not taken into account at this stage - this merely checks whether the basic item
* can be added.
* @param {Item} newItem - The new item to add
* @param {AppearanceUpdateParameters} params - The appearance update parameters that apply to the diff
* @returns {boolean} - TRUE if the new item can be equipped based on the appearance update parameters, FALSE otherwise
*/
function ValidationCanAddItem(newItem, params) {
const {C, fromSelf, sourceMemberNumber} = params;
// If the update is coming from ourself, it's always permitted
if (fromSelf) return true;
const asset = newItem.Asset;
// If the item is blocked/limited and the source doesn't have the correct permission, prevent it from being added
const type = (newItem.Property && newItem.Property.Type) || null;
const itemBlocked = ValidationIsItemBlockedOrLimited(C, sourceMemberNumber, asset.Group.Name, asset.Name, type) ||
ValidationIsItemBlockedOrLimited(C, sourceMemberNumber, asset.Group.Name, asset.Name);
if (itemBlocked && OnlineGameAllowBlockItems()) return false;
// Fall back to common item add/remove validation
return ValidationCanAddOrRemoveItem(newItem, params);
}
/**
* Determines whether the character described by the `sourceMemberNumber` parameter is permitted to add a given asset to
* the target character `C`, based on the asset's group name, asset name and type (if applicable). This only checks
* against the target character's limited and blocked item lists, not their global item permissions.
* @param {Character} C - The target character
* @param sourceMemberNumber - The member number of the source character
* @param {string} groupName - The name of the asset group for the intended item
* @param {string} assetName - The asset name of the intended item
* @param {string|null} [type] - The type of the intended item
* @returns {boolean} - TRUE if the character with the provided source member number is _not_ allowed to equip the
* described asset on the target character, FALSE otherwise.
*/
function ValidationIsItemBlockedOrLimited(C, sourceMemberNumber, groupName, assetName, type) {
if (C.MemberNumber === sourceMemberNumber) return false;
if (InventoryIsPermissionBlocked(C, assetName, groupName, type)) return true;
if (!InventoryIsPermissionLimited(C, assetName, groupName, type)) return false;
if (C.IsLoverOfMemberNumber(sourceMemberNumber) || C.IsOwnedByMemberNumber(sourceMemberNumber)) return false;
// If item permission is "Owner, Lover, whitelist & Dominants" or below, the source must be on their whitelist
if (C.ItemPermission < 3 && C.WhiteList.includes(sourceMemberNumber)) return false;
// Otherwise, the item is limited, and the source doesn't have permission
return true;
}
/**
* Determines whether an item can be removed from the target character, based on the provided appearance update
* parameters.
* @param {Item} previousItem - The item to remove
* @param {AppearanceUpdateParameters} params - The appearance update parameters that apply to the diff
* @param {boolean} isSwap - Whether or not the removal is part of a swap, which allows temporary removal of items with
* `AllowNone: false`.
* @returns {boolean} - TRUE if the item can be removed based on the appearance update parameters, FALSE otherwise
*/
function ValidationCanRemoveItem(previousItem, params, isSwap) {
// If we're not swapping, and the asset group can't be empty, always block removal
if (!previousItem.Asset.Group.AllowNone && !isSwap) return false;
const {fromSelf, fromOwner, fromLover} = params;
// If the update is coming from ourself, it's always permitted
if (fromSelf) return true;
const lock = InventoryGetLock(previousItem);
// If the previous item has AllowRemoveExclusive, allow owner/lover-only items to be removed if they're not locked
if (previousItem.Asset.AllowRemoveExclusive) {
if (InventoryOwnerOnlyItem(previousItem) && (!lock || !lock.Asset.OwnerOnly)) return true;
else if (InventoryLoverOnlyItem(previousItem) && (!lock || !lock.Asset.LoverOnly)) return true;
}
// Owners can always remove lover locks, regardless of lover rules
if (lock && lock.Asset.LoverOnly && fromOwner) return true;
// Only owners/lovers can remove lover locks
if (lock && lock.Asset.LoverOnly && !fromLover && !fromOwner) return false;
// Only owners can remove owner locks
if (lock && lock.Asset.OwnerOnly && !fromOwner) return false;
// Fall back to common item add/remove validation
return ValidationCanAddOrRemoveItem(previousItem, params);
}
/**
* Determines whether an item can be added or removed from the target character, based on the provided appearance update
* parameters.
* @param {Item} item - The item to add or remove
* @param {AppearanceUpdateParameters} params - The appearance update parameters that apply to the diff
* @return {boolean} - TRUE if the item can be added or removed based on the appearance update parameters, FALSE
* otherwise
*/
function ValidationCanAddOrRemoveItem(item, { C, fromOwner, fromLover }) {
const asset = item.Asset;
// If the target does not permit full appearance modification, block changing non-clothing appearance items
const blockFullWardrobeAccess = !(C.OnlineSharedSettings && C.OnlineSharedSettings.AllowFullWardrobeAccess);
if (blockFullWardrobeAccess && asset.Group.Category === "Appearance" && !asset.Group.Clothing) return false;
// If changing cosplay items is blocked and we're adding/removing a cosplay item, block it
const blockBodyCosplay = C.OnlineSharedSettings && C.OnlineSharedSettings.BlockBodyCosplay;
if (blockBodyCosplay && InventoryGetItemProperty(item, "BodyCosplay", true)) return false;
// If the item is owner only, only the owner can add/remove it
if (asset.OwnerOnly) return fromOwner;
// If the item is lover only, only a lover/owner can add/remove it
if (asset.LoverOnly) return fromLover;
// If the asset does not have the Enable flag, it can't be added/removed
if (!asset.Enable) return false;
// Otherwise, the item can be added/removed
return true;
}
/**
* Sanitizes the properties on an appearance item to ensure that no invalid properties are present. This removes invalid
* locks, strips invalid values, and ensures property values are within the constraints defined by an item.
* @param {Character} C - The character on whom the item is equipped
* @param {Item} item - The appearance item to sanitize
* @returns {boolean} - TRUE if the item was modified as part of the sanitization process (indicating that invalid
* properties were present), FALSE otherwise
*/
function ValidationSanitizeProperties(C, item) {
// If the character is an NPC, no validation is needed
if (C.IsNpc()) return false;
const property = item.Property;
// If the item doesn't have a property, no validation is needed
if (property == null) return false;
// If the property is not an object, remove it and return
if (typeof property !== "object") {
console.warn("Removing invalid property:", property);
delete item.Property;
return true;
}
// Sanitize various properties
let changed = ValidationSanitizeEffects(C, item);
changed = ValidationSanitizeBlocks(C, item) || changed;
changed = ValidationSanitizeSetPose(C, item) || changed;
changed = ValidationSanitizeStringArray(property, "Hide") || changed;
const asset = item.Asset;
// If the property has a type, it needs to be in the asset's AllowType array
const allowType = asset.AllowType || [];
if (property.Type != null && !allowType.includes(property.Type)) {
console.warn(`Removing invalid type "${property.Type}" from ${asset.Name}`);
delete property.Type;
changed = true;
}
// If the property has an expression, it needs to be in the asset or group's AllowExpression array
const allowExpression = asset.AllowExpression || asset.Group.AllowExpression || [];
if (property.Expression != null && !allowExpression.includes(property.Expression)) {
console.warn(`Removing invalid expression "${property.Expression}" from ${asset.Name}`);
delete property.Expression;
changed = true;
}
// Clamp item opacity within the allowed range
if (property && typeof property.Opacity === "number") {
if (property.Opacity > asset.MaxOpacity) {
property.Opacity = asset.MaxOpacity;
changed = true;
}
if (property.Opacity < asset.MinOpacity) {
property.Opacity = asset.MinOpacity;
changed = true;
}
}
// Remove invalid properties from non-typed items
if (!asset.AllowType || !asset.AllowType.length) {
["SetPose", "Difficulty", "SelfUnlock", "Hide"].forEach(P => {
if (property[P] != null) {
console.warn(`Removing invalid property "${P}" from ${asset.Name}`);
delete property[P];
changed = true;
}
});
}
// Block advanced vibrator modes if disabled
if (typeof property.Mode === "string" && C.ArousalSettings && C.ArousalSettings.DisableAdvancedVibes && !VibratorModeOptions[VibratorModeSet.STANDARD].includes(VibratorModeGetOption(property.Mode))) {
console.warn(`Removing invalid mode "${property.Mode}" from ${asset.Name}`);
property.Mode = VibratorModeOptions[VibratorModeSet.STANDARD][0].Name;
changed = true;
}
return changed;
}
/**
* Sanitizes the `Effect` array on an item's Property object, if present. This ensures that it is a valid array of
* strings, and that each item in the array is present in the asset's `AllowEffect` array.
* @param {Character} C - The character on whom the item is equipped
* @param {Item} item - The item whose `Effect` property should be sanitized
* @returns {boolean} - TRUE if the item's `Effect` property was modified as part of the sanitization process
* (indicating it was not a valid string array, or that invalid effects were present), FALSE otherwise
*/
function ValidationSanitizeEffects(C, item) {
const property = item.Property;
let changed = ValidationSanitizeStringArray(property, "Effect");
changed = ValidationSanitizeLock(C, item) || changed;
// If there is no Effect array, no further sanitization is needed
if (!Array.isArray(property.Effect)) return changed;
const assetEffect = item.Asset.Effect || [];
const allowEffect = item.Asset.AllowEffect || [];
property.Effect = property.Effect.filter((effect) => {
// The Lock effect is handled by ServerSanitizeLock
if (effect === "Lock") return true;
// All other effects must be included in the AllowEffect array to be permitted
else if (!assetEffect.includes(effect) && !allowEffect.includes(effect)) {
console.warn(`Filtering out invalid Effect entry on ${item.Asset.Name}:`, effect);
changed = true;
return false;
} else return true;
});
return changed;
}
/**
* Sanitizes an item's lock properties, if present. This ensures that any lock on the item is valid, and removes or
* corrects invalid properties.
* @param {Character} C - The character on whom the item is equipped
* @param {Item} item - The item whose lock properties should be sanitized
* @returns {boolean} - TRUE if the item's properties were modified as part of the sanitization process (indicating the
* lock was not valid), FALSE otherwise
*/
function ValidationSanitizeLock(C, item) {
const asset = item.Asset;
const property = item.Property;
// If there is no lock effect present, strip out any lock-related properties
if (!Array.isArray(property.Effect) || !property.Effect.includes("Lock")) return ValidationDeleteLock(property);
const lock = InventoryGetLock(item);
// If there is no lock, or the asset does not permit locks, or
if (
!asset.AllowLock ||
!lock ||
property.AllowLock === false ||
(asset.AllowLockType && !asset.AllowLockType.includes(property.Type))
) {
return ValidationDeleteLock(property);
}
let changed = false;
// Remove any invalid lock member number
const lockNumber = property.LockMemberNumber;
if (lockNumber != null && typeof lockNumber !== "number") {
console.warn("Removing invalid lock member number:", lockNumber);
delete property.LockMemberNumber;
changed = true;
}
// The character's member number is always valid on a lock
if (lockNumber !== C.MemberNumber) {
const ownerNumber = C.Ownership && C.Ownership.MemberNumber;
const hasOwner = typeof ownerNumber === "number";
const lockedByOwner = hasOwner && lockNumber === ownerNumber;
// Ensure the lock member number is valid on owner-only locks
if (lock.Asset.OwnerOnly && !lockedByOwner) {
console.warn(`Removing invalid owner-only lock with member number: ${lockNumber}`);
return ValidationDeleteLock(property);
}
const lockedByLover = C.GetLoversNumbers().includes(lockNumber);
// Ensure the lock member number is valid on lover-only locks
if (lock.Asset.LoverOnly && !lockedByOwner && !lockedByLover) {
console.warn(`Removing invalid lover-only lock with member number: ${lockNumber}`);
return ValidationDeleteLock(property);
}
}
// Sanitize combination lock number
if (typeof property.CombinationNumber === "string") {
if (!ValidationCombinationNumberRegex.test(property.CombinationNumber)) {
// If the combination is invalid, reset to 0000
console.warn(
`Invalid combination number: ${property.CombinationNumber}. Combination will be reset to ${ValidationDefaultCombinationNumber}`
);
property.CombinationNumber = ValidationDefaultCombinationNumber;
changed = true;
}
} else if (property.CombinationNumber != null) {
delete property.CombinationNumber;
changed = true;
}
// Sanitize lock password
if (typeof property.Password === "string") {
if (!ValidationPasswordRegex.test(property.Password)) {
// If the password is invalid, reset to "UNLOCK"
console.warn(
`Invalid password: ${property.Password}. Combination will be reset to ${ValidationDefaultPassword}`
);
property.Password = ValidationDefaultPassword;
changed = true;
}
} else if (property.Password != null) {
delete property.Password;
changed = true;
}
// Sanitize timer lock remove timers
if (lock.Asset.RemoveTimer > 0 && typeof property.RemoveTimer === "number") {
// Ensure the lock's remove timer doesn't exceed the maximum for that lock type
if (property.RemoveTimer - ValidationRemoveTimerToleranceMs > CurrentTime + lock.Asset.MaxTimer * 1000) {
property.RemoveTimer = Math.round(CurrentTime + lock.Asset.MaxTimer * 1000);
changed = true;
}
} else if (property.RemoveTimer != null) {
delete property.RemoveTimer;
changed = true;
}
return changed;
}
/**
* Sanitizes the `Block` array on an item's Property object, if present. This ensures that it is a valid array of
* strings, and that each item in the array is present in the either the asset's `Block` or `AllowBlock` array.
* @param {Character} C - The character on whom the item is equipped
* @param {Item} item - The item whose `Block` property should be sanitized
* @returns {boolean} - TRUE if the item's `Block` property was modified as part of the sanitization process
* (indicating it was not a valid string array, or that invalid entries were present), FALSE otherwise
*/
function ValidationSanitizeBlocks(C, item) {
const property = item.Property;
let changed = ValidationSanitizeStringArray(property, "Block");
// If there is no Block array, no further sanitization is needed
if (!Array.isArray(property.Block)) return changed;
const assetBlock = item.Asset.Block || [];
const allowBlock = item.Asset.AllowBlock || [];
// Any Block entry must be included in the AllowBlock list to be permitted
property.Block = property.Block.filter((block) => {
if (!assetBlock.includes(block) && !allowBlock.includes(block)) {
console.warn(`Filtering out invalid Block entry on ${item.Asset.Name}:`, block);
changed = true;
return false;
} else return true;
});
return changed;
}
/**
* Sanitizes the `SetPose` array on an item's Property object, if present. This ensures that it is a valid array of
* strings, and that each item in the array is present in the list of poses available in the game.
* @param {Character} C - The character on whom the item is equipped
* @param {Item} item - The item whose `SetPose` property should be sanitized
* @returns {boolean} - TRUE if the item's `SetPose` property was modified as part of the sanitization process
* (indicating it was not a valid string array, or that invalid entries were present), FALSE otherwise
*/
function ValidationSanitizeSetPose(C, item) {
const property = item.Property;
let changed = ValidationSanitizeStringArray(property, "SetPose");
// If there is no SetPose array, no further sanitization is needed
if (!Array.isArray(property.SetPose)) return changed;
// The SetPose array must contain a list of valid pose names
property.SetPose = property.SetPose.filter((pose) => {
if (!PoseFemale3DCGNames.includes(pose)) {
console.warn(`Filtering out invalid SetPose entry on ${item.Asset.Name}:`, pose);
changed = true;
return false;
} else return true;
});
return changed;
}
/**
* Sanitizes a property on an object to ensure that it is a valid string array or null/undefined. If the property is not
* a valid array and is not null, it will be deleted from the object. If it is a valid array, any non-string entries
* will be removed.
* @param {object} property - The object whose property should be sanitized
* @param {string} key - The key indicating which property on the object should be sanitized
* @returns {boolean} - TRUE if the object's property was modified as part of the sanitization process (indicating that
* the property was not a valid array, or that it contained a non-string entry), FALSE otherwise
*/
function ValidationSanitizeStringArray(property, key) {
const value = property[key];
let changed = false;
if (Array.isArray(value)) {
value.filter(str => {
if (typeof str !== "string") {
console.warn(`Filtering out invalid ${key} entry:`, str);
changed = true;
return false;
} else {
return true;
}
});
} else if (value != null) {
console.warn(`Removing invalid ${key} array:`, value);
delete property[key];
changed = true;
}
return changed;
}
/**
* Completely removes a lock from an item's Property object. This removes all lock-related properties, and the "Lock"
* effect from the property object.
* @param {object} property - The Property object to remove the lock from
* @param {boolean} verbose - Whether or not to print console warnings when properties are deleted. Defaults to true.
* @returns {boolean} - TRUE if the Property object was modified as a result of the lock deletion (indicating that at
* least one lock-related property was present), FALSE otherwise
*/
function ValidationDeleteLock(property, verbose = true) {
let changed = false;
if (property) {
ValidationAllLockProperties.forEach(key => {
if (property[key] != null) {
// Special casing for RemoveTimer because it is used for both locks and expressions :(
if (key === "RemoveTimer" && property.Expression != null) return;
// Otherwise remove the property
if (verbose) console.warn("Removing invalid lock property:", key);
delete property[key];
changed = true;
}
});
if (Array.isArray(property.Effect)) {
property.Effect = property.Effect.filter(E => {
if (E === "Lock") {
if (verbose) console.warn("Filtering out invalid Lock effect");
changed = true;
return false;
} else return true;
});
}
}
return changed;
}
/**
* Fixes any cyclic blocks in the provided appearance array. The given diff map is used to determine the order in which
* items should be rolled back or removed if block cycles are found (a block cycle being a series of items that block
* each other in a cyclic fashion).
* @param {Item[]} appearance - The appearance to sanitize
* @param {AppearanceDiffMap} diffMap - The appearance diff map which indicates the items that were changed as part of
* the appearance update that triggered this validation
* @returns {AppearanceValidationWrapper} - A wrapper containing the final appearance, with any block cycles removed,
* plus a valid flag indicating whether or not the appearance had to be modified.
*/
function ValidationResolveCyclicBlocks(appearance, diffMap) {
let cycles = ValidationFindBlockCycles(appearance);
let valid = true;
// Keep rolling items back until there are no block cycles left
while (cycles.length > 0) {
const groups = appearance.map((item) => item.Asset.Group.Name);
/* @type Map<string, number> */
const groupCounts = new Map();
// Count how many cycles each group appears in
for (const group of groups) {
for (const cycle of cycles) {
if (cycle.includes(group)) {
groupCounts.set(group, (groupCounts.get(group) || 0) + 1);
}
}
}
// Sort the groups - groups that appear in more cycles should be removed first
const groupsByFrequency = Array.from(groupCounts.entries())
.sort(([, c1], [, c2]) => c2 - c1)
.map(entry => entry[0]);
const nonModifiedGroups = [];
/*
* Find the groups that were modified in the provided diff map with changes that may impact blocking. These
* groups are the highest priorities for rollback/removal
*/
const modifiedGroups = groupsByFrequency.filter((group) => {
if (diffMap[group]) {
const [prev, next] = diffMap[group];
if (!!prev !== !!next) return true;
if (!CommonDeepEqual(prev.Asset.Block, next.Asset.Block)) return true;
const prevPropBlock = prev.Property && prev.Property.Block;
const nextPropBlock = next.Property && next.Property.Block;
if (!CommonDeepEqual(prevPropBlock, nextPropBlock)) return true;
const prevEnclose = InventoryItemHasEffect(prev, "Enclose");
const nextEnclose = InventoryItemHasEffect(next, "Enclose");
if (prevEnclose !== nextEnclose) return true;
}
nonModifiedGroups.push(group);
return false;
});
const groupsByPriority = modifiedGroups.concat(nonModifiedGroups);
let i = 0;
// Remove groups in priority order until there are no cycles left
while (cycles.length > 0 && i < groupsByPriority.length) {
const groupToRollback = groupsByPriority[i];
console.warn(`Rolling back group ${groupToRollback} due to block cycles`);
valid = false;
// Modify the appearance by rolling back or removing the item in the current group
appearance = appearance
.map((item) => {
const groupName = item.Asset.Group.Name;
if (groupName === groupToRollback) {
if (modifiedGroups.includes(groupName) && item !== diffMap[groupName][0]) {
/*
* If the group was modified as part of the diff map, and we're not already looking at the
* rolled back item, roll back
*/
return diffMap[groupName][0];
} else {
// Otherwise remove
return null;
}
}
// If it's not the group we care about, don't modify
return item;
})
.filter(Boolean);
// Remove any cycles that contain the group we just removed/rolled back
cycles = cycles.filter(cycle => !cycle.includes(groupToRollback));
i++;
}
// Finally, do one more cycle check to verify that the rollbacks didn't reveal more cycles
cycles = ValidationFindBlockCycles(appearance);
}
return { appearance, valid };
}
/**
* Finds any block cycles in the given appearance array. Block cycles are groups of items that block each other in a
* cyclic fashion. Block cycles are represented as an array of group names that comprise the cycle. For example:
* ["ItemArms", "ItemDevices", "ItemArms"]
* This indicates that the item in the ItemArms group blocks the item in the ItemDevices group, and vice versa. This
* function returns an array of such block cycles, or an empty array if none were found.
* Be advised that cyclic block checking is relatively expensive, so should only be run when needed - don't run it every
* frame!
* @param {Item[]} appearance - The appearance array to check
* @returns {string[][]} - A list of block cycles, each cycle being represented as an array of group names.
*/
function ValidationFindBlockCycles(appearance) {
const groups = appearance.map((item) => item.Asset.Group.Name);
/** @type {[string, string][]} */
const edges = [];
/** @type {Map<string, string>} */
const edgeMap = new Map();
/** @type {(from: string, to: string) => void} */
const recordEdge = (from, to) => {
if (!edgeMap.get(from)) {
edgeMap.set(from, to);
edges.push([from, to]);
}
};
for (const item of appearance) {
const blockedGroups = ValidationGetBlockedGroups(item, groups);
for (const group of blockedGroups) {
recordEdge(item.Asset.Group.Name, group);
}
const blockingGroups = ValidationGetPrerequisiteBlockingGroups(item, appearance);
for (const group of blockingGroups) {
recordEdge(group, item.Asset.Group.Name);
}
}
const graph = new DirectedGraph(groups, edges);
return graph.findCycles();
}
/**
* Finds the groups, from a provided list of groups, that are blocked by a given item.
* @param {Item} item - The item to check
* @param {string[]} groupNames - A list of group names that should be used to filter the final block list
* @returns {string[]} - A subset of the provided group names representing the groups that are blocked by the given
* item.
*/
function ValidationGetBlockedGroups(item, groupNames) {
if (InventoryItemHasEffect(item, "Enclose", true)) {
return groupNames.filter(groupName => groupName !== item.Asset.Group.Name);
}
let blockedGroups = [];
if (Array.isArray(item.Asset.Block)) {
blockedGroups = blockedGroups.concat(item.Asset.Block);
}
if (item.Property && Array.isArray(item.Property.Block)) {
blockedGroups = blockedGroups.concat(item.Property.Block);
}
return blockedGroups.filter(groupName => groupNames.includes(groupName));
}
/**
* Finds the groups from the provided appearance array that block a given item due to prerequisites. In this situation,
* an item is considered to be blocking if the target item can't be added with it, but can without it.
* @param {Item} item - The item to check
* @param {Item[]} appearance - The appearance array to check
* @returns {string[]} - A list of group names corresponding to items from the appearance array that block the given
* item due to prerequisites
*/
function ValidationGetPrerequisiteBlockingGroups(item, appearance) {
if (!item.Asset.Prerequisite) return [];
appearance = appearance.filter((appearanceItem) => appearanceItem.Asset !== item.Asset);
const char = CharacterLoadSimple(`PrerequisiteCheck${item.Asset.Group.Name}`);
/** @type {string[]} */
const blockingGroups = [];
for (const checkItem of appearance) {
char.Appearance = appearance;
CharacterLoadEffect(char);
CharacterLoadPose(char);
const allowedWithCheckItem = InventoryAllow(char, item.Asset, undefined, false);
if (!allowedWithCheckItem) {
char.Appearance = appearance.filter((appearanceItem) => appearanceItem.Asset !== checkItem.Asset);
CharacterLoadEffect(char);
CharacterLoadPose(char);
const allowedWithoutCheckItem = InventoryAllow(char, item.Asset, undefined, false);
if (allowedWithoutCheckItem) {
blockingGroups.push(checkItem.Asset.Group.Name);
}
}
}
return blockingGroups;
}