bondage-college-mirr/BondageClub/Tools/Node/AssetCheck.js

1275 lines
39 KiB
JavaScript

"use strict";
const vm = require("vm");
const fs = require("fs");
const { NEEDED_FILES, BASE_PATH, error, loadCSV } = require("./Common.js");
const common = require("./Common.js");
/**
* Checks for {@link AssetDefinition.DynamicGroupName}
* @param {readonly AssetGroupDefinition[]} groupDefinitions
*/
function testDynamicGroupName(groupDefinitions) {
/** @type {[AssetGroupDefinition, AssetDefinition][]} */
const assetsList = [];
for (const groupDef of groupDefinitions) {
for (const asset of groupDef.Asset) {
if (typeof asset !== "string") {
assetsList.push([groupDef, asset]);
}
}
}
// If `DynamicGroupName` is set, check whether the dynamically referenced item actually exists
for (const [groupDef, assetDef] of assetsList) {
const DynamicGroupName = (assetDef.DynamicGroupName !== undefined) ? assetDef.DynamicGroupName : groupDef.DynamicGroupName;
if (
DynamicGroupName !== undefined
&& DynamicGroupName !== groupDef.Group
&& !assetsList.some(([g, a]) => a.Name === assetDef.Name && g.Group === DynamicGroupName)
) {
error(`${groupDef.Group}:${assetDef.Name}: Missing DynamicGroupName-referenced item: ${DynamicGroupName}:${assetDef.Name}`);
}
}
}
/**
* Flatten and yield all combined Asset/Group configs and names
* @param {ExtendedItemMainConfig} extendedItemConfig
*/
function* flattenExtendedConfig(extendedItemConfig) {
for (const [groupName, groupConfig] of Object.entries(extendedItemConfig)) {
for (const [assetName, assetConfig] of Object.entries(groupConfig)) {
yield { groupName, assetName, groupConfig, assetConfig };
}
}
}
/**
* Checks for the option names of typed items
* @param {ExtendedItemMainConfig} extendedItemConfig
* @param {string[][]} dialogArray
*/
function testExtendedItemDialog(extendedItemConfig, dialogArray) {
const dialogSet = new Set(dialogArray.map(i => i[0]));
for (const { groupName, assetName, assetConfig } of flattenExtendedConfig(extendedItemConfig)) {
// Skip if dialog keys if they are set via CopyConfig;
// they will be checked when validating the parent item
if (assetConfig.CopyConfig && !assetConfig?.DialogPrefix) {
continue;
}
/** @type {Set<string>} */
let missingDialog = new Set();
switch (assetConfig.Archetype) {
case "typed":
missingDialog = testTypedItemDialog(groupName, assetName, assetConfig, dialogSet);
break;
case "modular":
missingDialog = testModularItemDialog(groupName, assetName, assetConfig, dialogSet);
break;
}
if (missingDialog.size !== 0) {
const missingString = Array.from(missingDialog).sort();
error(`${groupName}:${assetName}: found ${missingDialog.size} missing dialog keys: ${missingString}`);
}
}
}
/**
* Construct all prefix/suffix combinations and add them to `diffSet` if they are not part of `referenceSet`.
* Performs an inplace update of `diffSet`.
* @param {readonly (undefined | string)[]} prefixIter
* @param {readonly (undefined | string)[]} suffixIter
* @param {Readonly<Set<string>>} referenceSet
* @param {Set<string>} diffSet
*/
function gatherDifference(prefixIter, suffixIter, referenceSet, diffSet) {
for (const prefix of prefixIter) {
if (prefix === undefined) {
continue;
}
for (const suffix of suffixIter) {
if (suffix === undefined) {
continue;
}
const key =`${prefix}${suffix}`;
if (!referenceSet.has(key)) {
diffSet.add(key);
}
}
}
}
/**
* Version of {@link gatherDifference} designed for handling the `fromTo` typed item chat setting.
* @param {readonly (undefined | string)[]} prefixIter
* @param {readonly (undefined | string)[]} suffixIter
* @param {Readonly<Set<string>>} referenceSet
* @param {Set<string>} diffSet
*/
function gatherDifferenceFromTo(prefixIter, suffixIter, referenceSet, diffSet) {
for (const prefix of prefixIter) {
if (prefix === undefined) {
continue;
}
for (const suffix1 of suffixIter) {
for (const suffix2 of suffixIter) {
if (suffix1 === suffix2 || suffix1 === undefined || suffix2 === undefined) {
continue;
}
const key =`${prefix}${suffix1}To${suffix2}`;
if (!referenceSet.has(key)) {
diffSet.add(key);
}
}
}
}
}
/**
* Return a set of all expected typed item dialog keys that are absent for a given iten.
* Helper function for {@link testExtendedItemDialog}.
* @param {string} groupName
* @param {string} assetName
* @param {TypedItemConfig} config
* @param {Readonly<Set<string>>} dialogSet
* @returns {Set<string>}
*/
function testTypedItemDialog(groupName, assetName, config, dialogSet) {
const chatSetting = config.ChatSetting ?? "default";
/** @type {Partial<TypedItemConfig["DialogPrefix"]>} */
const dialogConfig = {
Header: `${groupName}${assetName}Select`,
Option: `${groupName}${assetName}`,
Chat: `${groupName}${assetName}Set`,
...(config.DialogPrefix ?? {}),
};
if (
typeof dialogConfig.Chat === "function" // Can't validate callables via the CI
|| chatSetting === "silent" // No dialog when silent
|| !groupName.includes("Item") // Type changes of clothing based items never have a chat message
) {
dialogConfig.Chat = undefined;
}
/** @type {Set<string>} */
const ret = new Set();
const optionNames = config.Options?.map(o => !o.HasSubscreen ? o.Name : undefined) ?? [];
gatherDifference([dialogConfig.Option], optionNames, dialogSet, ret);
gatherDifference([dialogConfig.Header], [""], dialogSet, ret);
if (chatSetting === "default") {
gatherDifference([dialogConfig.Chat], optionNames, dialogSet, ret);
} else if (chatSetting === "fromTo") {
gatherDifferenceFromTo([dialogConfig.Chat], optionNames, dialogSet, ret);
}
return ret;
}
/**
* Return a set of all expected modular item dialog keys that are absent for a given iten.
* Helper function for {@link testExtendedItemDialog}.
* @param {string} groupName
* @param {string} assetName
* @param {ModularItemConfig} config
* @param {Readonly<Set<string>>} dialogSet
* @returns {Set<string>}
*/
function testModularItemDialog(groupName, assetName, config, dialogSet) {
const chatSetting = config.ChatSetting ?? "default";
/** @type {Partial<ModularItemConfig["DialogPrefix"]>} */
const dialogConfig = {
Header: `${groupName}${assetName}Select`,
Module: `${groupName}${assetName}Module`,
Option: `${groupName}${assetName}Option`,
Chat: `${groupName}${assetName}Set`,
...(config.DialogPrefix ?? {}),
};
if (typeof dialogConfig.Chat === "function" || !groupName.includes("Item")) {
dialogConfig.Chat = undefined;
}
const modulesNames = config.Modules?.map(m => m.Name) ?? [];
/** @type {(string | undefined)[]} */
const optionNames = [];
for (const module of config.Modules ?? []) {
optionNames.push(...(module.Options.map((o, i) => !o.HasSubscreen ? `${module.Key}${i}` : undefined) ?? []));
}
/** @type {Set<string>} */
const ret = new Set();
gatherDifference([dialogConfig.Header, dialogConfig.Module], modulesNames, dialogSet, ret);
gatherDifference([dialogConfig.Option], optionNames, dialogSet, ret);
if (chatSetting === "default") {
gatherDifference([dialogConfig.Chat], optionNames, dialogSet, ret);
} else if (chatSetting === "perModule") {
// Ignore a module if every single one of its options links to a subscreen
const modulesNamesNoSubscreen = config.Modules?.map(m => m.Options.every(o => o.HasSubscreen) ? undefined : m.Name) ?? [];
gatherDifference([dialogConfig.Chat], modulesNamesNoSubscreen, dialogSet, ret);
}
return ret;
}
/**
* Check that all expected color-group entries are present in the .csv file
* @param {TestingStruct<string>[]} missingGroups A list of all missing color groups
*/
function testColorGroups(missingGroups) {
if (!Array.isArray(missingGroups)) {
error("MISSING_COLOR_GROUPS not found");
}
for (const { Group, Name, Invalid } of missingGroups) {
error(`${Group}:${Name}: Missing color group "${Invalid}"`);
}
}
/**
* Check that all expected color-layer entries are present in the .csv file
* @param {TestingStruct<string>[]} missingLayers A list of all missing color layers
*/
function testColorLayers(missingLayers) {
if (!Array.isArray(missingLayers)) {
error("MISSING_COLOR_LAYERS not found");
return;
}
for (const { Group, Name, Invalid } of missingLayers) {
error(`${Group}:${Name}: Missing color layer "${Invalid}"`);
}
}
/**
* Test whether all asset default colors are valid.
* @param {TestingStruct<string[]>[]} invalidDefaults A list of all missing color layers
*/
function testDefaultColor(invalidDefaults) {
if (!Array.isArray(invalidDefaults)) {
error("TestingInvalidDefaultColor not found");
return;
}
for (const { Group, Name, Invalid } of invalidDefaults) {
error(`${Group}:${Name}: ${Invalid.length} invalid color defaults "${Invalid}"`);
}
}
/**
* Gather all duplicate module/option names from the passed module/option list
* @param {readonly { Name: string }[]} options
* @returns {string[]}
*/
function gatherDuplicateOptionNames(options) {
/** @type {Record<string, number>} */
const nameCount = {};
for (const option of options) {
if (option.Name in nameCount) {
nameCount[option.Name] += 1;
} else {
nameCount[option.Name] = 1;
}
}
/** @type {string[]} */
const duplicates = [];
for (const [name, count] of Object.entries(nameCount)) {
if (count > 1) {
duplicates.push(name);
}
}
return duplicates;
}
/**
* Check whether all extended item options and modules are (at least) of length 1.
* @param {ExtendedItemMainConfig} config
*/
function testModuleOptionLength(config) {
for (const { groupName, assetName, assetConfig } of flattenExtendedConfig(config)) {
switch (assetConfig.Archetype) {
case "typed": {
testModuleOptionLengthTyped(groupName, assetName, assetConfig);
break;
}
case "modular": {
testModuleOptionLengthModular(groupName, assetName, assetConfig);
break;
}
}
}
}
/**
* @param {string} groupName
* @param {string} assetName
* @param {TypedItemConfig} config
*/
function testModuleOptionLengthTyped(groupName, assetName, config) {
if (config.CopyConfig && !config?.Options) {
return;
}
const options = config.Options ?? [];
if (options.length === 0) {
error(`${groupName}:${assetName}: typed item require at least one option`);
}
const duplicateNames = gatherDuplicateOptionNames(options);
if (duplicateNames.length) {
error(`${groupName}:${assetName}: found ${duplicateNames.length} typed item options with a duplicate Name: ${duplicateNames}`);
}
}
/**
* @param {string} groupName
* @param {string} assetName
* @param {ModularItemConfig} config
*/
function testModuleOptionLengthModular(groupName, assetName, config) {
if (config.CopyConfig && !config?.Modules) {
return;
}
const modules = config.Modules ?? [];
if (modules.length === 0) {
error(`${groupName}:${assetName}: modular item requires at least one option`);
}
for (const mod of modules) {
if (mod.Options.length === 0) {
error(`${groupName}:${assetName}: modular item module "${mod}" requires at least one option`);
}
}
const duplicateNames = gatherDuplicateOptionNames(modules);
if (duplicateNames.length) {
error(`${groupName}:${assetName}: found ${duplicateNames.length} modular item modules with a duplicate Name: ${duplicateNames}`);
}
}
/**
* Return the intersection between the passed sets
* @param {Set<string>[]} args
* @returns {string[]}
*/
function getIntersection(...args) {
if (args.length === 0) {
return [];
}
const intersection = new Set();
const elementsVisited = new Set(args[0]);
for (const set of args.slice(1)) {
for (const i of set) {
if (elementsVisited.has(i)) {
intersection.add(i);
} else {
elementsVisited.add(i);
}
}
}
return Array.from(intersection).sort();
}
/**
* @param {Record<string, ModularItemData>} dataRecord
*/
function testModularItemPropertyDisjoint(dataRecord) {
if (dataRecord === null || typeof dataRecord !== "object") {
error("ModularItemDataLookup not found");
return;
}
// Non-array item properties with special casing for dealing with inter-module intersections
const propertyWhitelist = new Set(["Difficulty", "OverrideHeight"]);
const assetWhitelist = new Set([
// `Intensity` intersection explicitly handled by a `...SetOption` script hook
"ItemVulva:ClitAndDildoVibratorbelt",
]);
for (const data of Object.values(dataRecord)) {
if (assetWhitelist.has(`${data.asset.Group.Name}:${data.asset.Name}`)) {
continue;
}
/** @type {Record<string, Set<string>>} */
const properties = {};
for (const mod of data.modules) {
properties[mod.Key] = new Set();
for (const option of mod.Options) {
for (const [k, v] of Object.entries(option.Property || {})) {
if (!Array.isArray(v) && !propertyWhitelist.has(k)) {
properties[mod.Key].add(k);
}
}
}
}
const intersection = getIntersection(...Object.values(properties));
if (intersection.length > 0) {
error(
`${data.asset.Group.Name}:${data.asset.Name}: found ${intersection.length} intersecting `
+ `cross-module item properties: ${intersection}`
);
}
}
}
/**
* @param {string} dir
* @param {Set<string>} pathSet
* @param {(file: string) => boolean} filter
*/
function recursivelyListDir(dir, pathSet, filter) {
fs.readdirSync(dir).forEach(f => {
let dirPath = `${dir}/${f}`;
const isDirectory = fs.statSync(dirPath).isDirectory();
if (isDirectory) {
recursivelyListDir(dirPath, pathSet, filter);
} else if (filter(f)) {
const offset = BASE_PATH.length;
pathSet.add(`${dir.slice(offset)}/${f}`);
}
});
}
/**
*
* @param {Record<ExtendedArchetype, Record<string, AssetArchetypeData>>} dataSuperRecord
*/
function testExtendedItemButtonImage(dataSuperRecord) {
// Gather all expected image paths
/** @type {Set<string>} */
const requiredImages = new Set();
for (const [archetype, dataRecord] of Object.entries(dataSuperRecord)) {
if (dataRecord === null || typeof dataRecord !== "object") {
error(`${archetype} data lookup not found`);
continue;
}
for (const data of Object.values(dataRecord)) {
if (data.archetype === "modular") {
// Check the modules' drawdata as well
for (const mod of data.modules) {
for (const buttonData of mod.drawData.elementData) {
if (buttonData.imagePath != null) {
requiredImages.add(buttonData.imagePath);
}
}
}
}
for (const buttonData of data.drawData.elementData) {
if ("imagePath" in buttonData && buttonData.imagePath != null) {
requiredImages.add(buttonData.imagePath);
}
}
}
}
// Identify all missing and extra images
/** @type {Set<string>} */
const availableImages = new Set();
recursivelyListDir(`${BASE_PATH}Screens/Inventory`, availableImages, i => i.endsWith(".png"));
for (const imagePath of requiredImages) {
if (!availableImages.has(imagePath)) {
error(`Missing extended item button image ${imagePath}`);
}
}
for (const imagePath of availableImages) {
if (!requiredImages.has(imagePath)) {
error(`Unexpected extra extended item button image ${imagePath}`);
}
}
}
/**
* @param {readonly Asset[]} assets
*/
function testAssetLayerNames(assets) {
if (assets === null || !Array.isArray(assets)) {
error("Asset not found");
return;
}
for (const asset of assets) {
/** @type {Record<string, number>} */
const layerCount = {};
for (const layer of asset.Layer) {
layerCount[layer.Name] = 1 + (layerCount[layer.Name] || 0);
}
const duplicateLayers = Object.entries(layerCount).filter(([_, j]) => j > 1).map(([i, _]) => i);
if (duplicateLayers.length > 0) {
error(
`${asset.Group.Name}:${asset.Name}: found ${duplicateLayers.length} layers `
+ `with duplicate names: ${duplicateLayers}`
);
}
}
}
/**
* Test whether a subset of asset and extended item properties are disjoint.
* The subset concerns those properties that BC always checks at both the asset and item level.
* @param {Record<ExtendedArchetype, Record<string, AssetArchetypeData>>} dataSuperRecord
*/
function testAssetAndExtendedPropertyIsDisjoint(dataSuperRecord) {
/**
* All array-like properties that BC checks at both the asset and item level.
* @satisfies {readonly (keyof ItemProperties)[]}
*/
const propNames = /** @type {const} */([
"Effect",
"Hide",
"HideItem",
"HideItemExclude",
"Block",
"AllowActivityOn",
"Fetish",
]);
// Gather all expected image paths
for (const [archetype, dataRecord] of Object.entries(dataSuperRecord)) {
if (dataRecord === null || typeof dataRecord !== "object") {
error(`${archetype} data lookup not found`);
continue;
}
for (const data of Object.values(dataRecord)) {
/** @type {ExtendedItemOption[]} */
const options = [];
if (data.archetype === "modular") {
// Check the modules' drawdata as well
for (const mod of data.modules) {
options.push(...mod.Options);
}
} else {
if ("options" in data && Array.isArray(data.options)) {
options.push(...data.options);
}
}
const asset = data.asset;
for (const propName of propNames) {
if (propName === "Effect") {
testAssetAndExtendedEffectIsDisjoint(options, asset);
}
/** @type {Set<string>} */
const propSet = new Set();
for (const option of options) {
/** @type {string[]} */
const propArray = (option.Property && option.Property[propName]) || [];
for (const i of propArray) {
propSet.add(i);
}
}
/** @type {string[]} */
const intersection = (asset[propName] || []).filter(i => propSet.has(i)).sort();
if (intersection.length > 0) {
error(
`${asset.Group.Name}:${asset.Name}: found ${intersection.length} intersection(s) `
+ `between asset and extended item option "${propName}" properties: ${intersection}`
);
}
}
}
}
}
/** @type {Set<GagEffectName>} */
const GAG_EFFECTS = new Set([
"GagVeryLight",
"GagEasy",
"GagLight",
"GagNormal",
"GagMedium",
"GagHeavy",
"GagVeryHeavy",
"GagTotal",
"GagTotal2",
"GagTotal3",
"GagTotal4",
]);
/** @type {Set<BlindEffectName>} */
const BLIND_EFFECTS = new Set([
"BlindLight",
"BlindNormal",
"BlindHeavy",
"BlindTotal",
]);
/** @type {Set<BlurEffectName>} */
const BLUR_EFFECTS = new Set([
"BlurLight",
"BlurNormal",
"BlurHeavy",
"BlurTotal",
]);
/** @type {Set<DeafEffectName>} */
const DEAF_EFFECTS = new Set([
"DeafLight",
"DeafNormal",
"DeafHeavy",
"DeafTotal",
]);
/**
* A {@link Set.has} variant for string-based sets that returns a typeguard.
* @template {string} T
* @param {Set<T>} set
* @param {string} value
* @returns {value is T}
*/
function setHas(set, value) {
return set.has(/** @type {T} */(value));
}
/**
* A helper for {@link testAssetAndExtendedPropertyIsDisjoint} that performs a stricter check for `Effect` disjointment.
* More specifically, this checks whether only a single effect (at most) of each of the following effect categories is present:
*
* * Gag level
* * Blindness level
* * Deafness level
* * Blur level
* @param {readonly ExtendedItemOption[]} options
* @param {Asset} asset
*/
function testAssetAndExtendedEffectIsDisjoint(options, asset) {
/** @type {Set<string>} */
const propSet = new Set();
for (const option of options) {
const propArray = (option.Property && option.Property.Effect) || [];
for (const i of propArray) {
if (setHas(GAG_EFFECTS, i)) {
propSet.add("Gag level");
}
if (setHas(BLIND_EFFECTS, i)) {
propSet.add("Blind level");
}
if (setHas(BLUR_EFFECTS, i)) {
propSet.add("Blur level");
}
if (setHas(DEAF_EFFECTS, i)) {
propSet.add("Deaf level");
}
}
}
/** @type {string[]} */
const intersection = [];
for (const i of asset.Effect) {
if (setHas(GAG_EFFECTS, i) && propSet.has("Gag level")) {
intersection.push("Gag level");
}
if (setHas(DEAF_EFFECTS, i) && propSet.has("Blind level")) {
intersection.push("Blind level");
}
if (setHas(BLUR_EFFECTS, i) && propSet.has("Blur level")) {
intersection.push("Blur level");
}
if (setHas(DEAF_EFFECTS, i) && propSet.has("Deaf level")) {
intersection.push("Deaf level");
}
}
if (intersection.length > 0) {
intersection.sort();
error(
`${asset.Group.Name}:${asset.Name}: found ${intersection.length} intersection(s) `
+ `between asset and extended item option "Effect" properties categories: ${intersection}`
);
}
}
/**
* @param {Record<string, TypedItemData | ModularItemData>} dataRecord
*/
function testAllowTypes(dataRecord) {
for (const data of Object.values(dataRecord)) {
const asset = data.asset;
const allowType = /** @type {readonly string[]} */(asset.AllowTypes);
for (const layer of asset.Layer) {
if (!layer.AllowTypes) {
continue;
}
const invalid = layer.AllowTypes.filter(t => t == "" ? false : !allowType.includes(t));
if (invalid.length > 0) {
error(
`${asset.Group.Name}:${asset.Name}: found ${invalid.length} invalid types `
+ `in the AllowTypes array of layer ${layer.Name}: ${invalid}`
);
}
}
}
}
/**
* @param {Record<string, ModularItemData>} dataRecord
*/
function testAllowModuleTypes(dataRecord) {
for (const data of Object.values(dataRecord)) {
const asset = data.asset;
const optionNames = data.modules.map(m => m.Options.map(o => o.Name)).flat();
for (const layer of asset.Layer) {
if (!layer.AllowModuleTypes) {
continue;
}
const invalid = layer.AllowModuleTypes.filter(t => {
for (const subType of (t.match(/([a-zA-Z]+\d+)/g) || [])) {
if (!optionNames.includes(subType)) {
return true;
}
}
return false;
});
if (invalid.length > 0) {
error(
`${asset.Group.Name}:${asset.Name}: found ${invalid.length} invalid (sub-)types `
+ `in the AllowModuleTypes array of layer ${layer.Name}: ${invalid}`
);
}
}
}
}
/**
* @param {readonly Asset[]} assets
*/
function testCopyLayerColor(assets) {
for (const asset of assets) {
const layerNames = asset.Layer.map(l => l.Name);
for (const layer of asset.Layer) {
if (layer.CopyLayerColor == null) {
continue;
}
if (!layerNames.includes(layer.CopyLayerColor)) {
error(
`${asset.Group.Name}:${asset.Name}: found an invalid `
+ `CopyLayerColor value in layer ${layer.Name}: ${layer.CopyLayerColor}`
);
}
}
}
}
/**
* Validate whether all extended item prerequisites of the default option are a subset of the asset-level prerequisites.
* @param {Record<ExtendedArchetype, Record<string, AssetArchetypeData>>} dataSuperRecord
*/
function testExtendedPrerequisite(dataSuperRecord) {
for (const dataRecord of Object.values(dataSuperRecord)) {
for (const data of Object.values(dataRecord)) {
const asset = data.asset;
/** @type {AssetPrerequisite[]} */
const extendedPrereq = [];
switch (data.archetype) {
case "typed":
case "vibrating": {
const prereq = data.options[0].Prerequisite;
if (Array.isArray(prereq)) {
extendedPrereq.push(...prereq);
} else if (typeof prereq === "string") {
extendedPrereq.push(prereq);
}
break;
}
case "modular": {
for (const mod of data.modules) {
const prereq = mod.Options[0].Prerequisite;
if (Array.isArray(prereq)) {
extendedPrereq.push(...prereq);
} else if (typeof prereq === "string") {
extendedPrereq.push(prereq);
}
}
break;
}
default:
break;
}
if (extendedPrereq.length === 0) {
continue;
}
/** @type {AssetPrerequisite[]} */
const invalidSuperset = [];
for (const prereq of extendedPrereq) {
if (!asset.Prerequisite.includes(prereq)) {
invalidSuperset.push(prereq);
}
}
if (invalidSuperset.length > 0) {
invalidSuperset.sort();
error(
`${asset.Group.Name}:${asset.Name}: default extended item prerequisites must `
+ `be a subset of asset-level prerequisites; offending members: ${invalidSuperset}`
);
}
}
}
}
/**
* Convert a flattened list of pose names to a record mapping pose categories to their respective names.
* @param {readonly AssetPoseName[]} poses - A list of to-be mapped poses
* @param {Record<AssetPoseCategory, Set<AssetPoseName>>} referencePoseMap - A record containing all pose categories and all their respective members
* @returns {Partial<Record<AssetPoseCategory, AssetPoseName[]>>} - A record containing all the subset of pose categories and members member names as specified in `poses`
*/
function poseToPoseMap(poses, referencePoseMap) {
/** @type {Partial<Record<AssetPoseCategory, AssetPoseName[]>>} */
const poseMap = {};
for (const pose of poses) {
for (const [_category, poseSet] of Object.entries(referencePoseMap)) {
const category = /** @type {AssetPoseCategory} */(_category);
if (poseSet.has(pose)) {
poseMap[category] = [...(poseMap[category] || []), pose];
break;
}
}
}
return poseMap;
}
/**
* Test that `AllowActivePose` is, on pose category by category basis, a superset of `PoseMapping` key.
* @param {readonly Asset[]} assets
* @param {Record<AssetPoseCategory, Set<AssetPoseName>>} poseMap
*/
function testActivePose(assets, poseMap) {
for (const asset of assets) {
// TODO: For archetypical items we have to check against a `AllowActivePose` union involving all extended item option allowed poses
if (!asset.AllowActivePose || asset.Archetype) {
continue;
}
const allowPoseMap = poseToPoseMap(/** @type {AssetPoseName[]} */(Object.keys(asset.PoseMapping)), poseMap);
const allowActivePoseMap = poseToPoseMap(asset.AllowActivePose ?? [], poseMap);
for (const [_category, allowActivePoseList] of Object.entries(allowActivePoseMap)) {
const category = /** @type {AssetPoseCategory} */(_category);
const allowPoseList = allowPoseMap[category];
if (!allowPoseList) {
continue;
}
const invalidSuperset = allowPoseList.filter(p => !allowActivePoseList.includes(p));
if (invalidSuperset.length > 0) {
invalidSuperset.sort();
error(
`${asset.Group.Name}:${asset.Name}: PoseMapping keys must be a subset of AllowActivePose for a given pose category; `
+ `found ${invalidSuperset.length} offending members in category "${category}": ${invalidSuperset}`
);
}
}
}
}
/**
* Check that all assets have a preview image
* @param {readonly Asset[]} assets
*/
function testHasPreview(assets) {
for (const asset of assets) {
const path = `${BASE_PATH}Assets/${asset.Group.Family}/${asset.DynamicGroupName}/Preview/${asset.Name}.png`;
if (asset.Visible && asset.Group.AllowNone && !fs.existsSync(path)) {
error(`${asset.Group.Name}:${asset.Name}: Missing preview image at "${path}"`);
}
}
}
/**
* Check that all assets have the expected .png images.
* @param {readonly Asset[]} assets
* @param {{ typed: Record<string, TypedItemData>, modular: Record<string, ModularItemData> }} extendedDataRecords
*/
function testAssetHasPNG(assets, extendedDataRecords) {
const assetGroups = /** @type {Record<AssetGroupName, AssetGroup>} */({});
const assetGroupMap = /** @type {Record<AssetGroupName, { F: string[], M: string[], FM: string[] }>} */({});
for (const asset of assets) {
const gender = asset.Gender ?? "FM";
if (!(asset.Group.Name in assetGroupMap)) {
assetGroupMap[asset.Group.Name] = { "F": [], "M": [], "FM": [] };
}
assetGroupMap[asset.Group.Name][gender].push(asset.Name);
assetGroups[asset.Group.Name] = asset.Group;
}
for (const asset of assets) {
if (!asset.Visible) {
continue;
}
for (const layer of asset.Layer) {
if (!layer.HasImage) {
continue;
}
// Combinations involving pose-specific directories
const poses = new Set(Object.values(layer.PoseMapping).filter(p => p !== "Hide"));
if (poses.size === 0) {
poses.add("");
}
// Combinations involving modular/typed item types
/** @type {{ type: string, option: ExtendedItemOption }[]} */
let layerOptions = [];
if (layer.HasType || layer.ModuleType) {
switch (asset.Archetype) {
case "typed": {
const data = extendedDataRecords.typed[`${asset.Group.Name}${asset.Name}`];
layerOptions = data.options.map((o, i) => {
const ret = { type: i === 0 ? "" : o.Name, option: o };
return (layer.AllowTypes?.includes(ret.type) ?? true) ? ret : /** @type {never} */(null);
}).filter(i => i != null);
break;
}
case "modular": {
if (!layer.ModuleType) {
break;
}
const data = extendedDataRecords.modular[`${asset.Group.Name}${asset.Name}`];
layerOptions = layer.ModuleType.flatMap(key => {
/** @type {null | string[]} */
let allowedTypes = layer.AllowModuleTypes?.filter(typ => key === typ.slice(0, key.length)) ?? [];
if (allowedTypes.length === 0) {
allowedTypes = null;
}
const module = data.modules.find(m => m.Key === key);
return (module?.Options ?? []).filter(o => allowedTypes?.includes(o.Name) ?? true).map(o => {
return { type: o.Name, option: o };
});
});
break;
}
}
}
// Extended item options can have a set of allowed poses distinct from their base asset
/** @type {[type: string | null, poses: Iterable<AssetPoseName | PoseType>][]} */
let layerTypes = [[null, poses]];
if (!layer.HasType && layer.AllowTypes && asset.Archetype === "typed") {
// We need special logic
const data = extendedDataRecords.typed[`${asset.Group.Name}${asset.Name}`];
/** @type {Set<AssetPoseName | PoseType>} */
const posesSubset = new Set();
layer.AllowTypes.forEach((name, i) => {
const option = i === 0 ? data.options[0] : data.options.find(o => o.Name === name);
for (const p of (option?.Property?.AllowActivePose ?? asset.AllowActivePose ?? [])) {
const mappedPose = layer.PoseMapping[p];
if (mappedPose && mappedPose !== "Hide") {
posesSubset.add(mappedPose);
}
}
});
if (posesSubset.size === 0) {
posesSubset.add("");
}
layerTypes = [[null, posesSubset]];
} else if (layerOptions.length !== 0) {
layerTypes = layerOptions.map(({ type, option }) => {
if (option.Property?.AllowActivePose) {
const allowedPoses = /** @type {Set<AssetPoseName | PoseType>} */(new Set(option.Property.AllowActivePose.filter(p => {
const mappedPose = layer.PoseMapping[p];
return mappedPose && mappedPose !== "Hide";
}).map(p => layer.PoseMapping[p])));
if (allowedPoses.size === 0) {
allowedPoses.add("");
}
return [type, allowedPoses];
} else {
return [type, poses];
}
});
}
// Combinations involving non-hex based colors (white, black, asian, etc)
/** @type {(string | null)[]} */
let colors;
if (layer.InheritColor) {
let colorParent = assetGroups[layer.InheritColor];
while (colorParent.InheritColor) {
colorParent = assetGroups[colorParent.InheritColor];
}
colors = colorParent.ColorSchema.map(i => layer.ColorSuffix?.[i] ?? i).filter(i => i !== "Default" && !i.startsWith("#"));
} else {
colors = asset.Group.ColorSchema.filter(i => i !== "Default" && !i.startsWith("#"));
}
if (colors.length === 0) {
colors = [null];
}
// Combinations involving parent group members
const parentNames = layer.ParentGroupName == null ? [null] : assetGroupMap[layer.ParentGroupName][asset.Gender ?? "FM"] ;
// Combinations involving expressions
/** @type {readonly (null | ExpressionName)[]} */
let expressions = layer.Asset.AllowExpression ?? [];
if (expressions.length === 0 || !layer.MirrorExpression) {
expressions = [null];
}
const root = `${BASE_PATH}Assets/${asset.Group.Family}/${asset.DynamicGroupName}/`;
for (const parent of parentNames) {
for (const color of colors) {
for (const expression of expressions) {
for (const [type, poseList] of layerTypes) {
for (const pose of poseList) {
const file = (
root
+ (pose ? `${pose}/` : "")
+ (expression ? `${expression}/` : "")
+ asset.Name
+ (parent ? `_${parent}` : "")
+ (type ?? "")
+ (color == null ? "" : `_${color}`)
+ (layer.Name == null ? ".png" : `_${layer.Name}.png`)
);
if (!fs.existsSync(file)) {
error(`${asset.Group.Name}:${asset.Name}:${layer.Name}: Missing asset "${file}"`);
}
}
}
}
}
}
}
}
}
/**
* Strigify and parse the passed object to get the correct Array and Object prototypes, because VM uses different ones.
* This unfortunately results in Functions being lost and replaced with a dummy function
* @param {any} input The to-be sanitized input
* @returns {any} The sanitized output
*/
function sanitizeVMOutput(input) {
return JSON.parse(
JSON.stringify(
input,
(key, value) => typeof value === "function" ? "__FUNCTION__" : value,
),
(key, value) => value === "__FUNCTION__" ? () => { return; } : value,
);
}
(function () {
const [commonFile, ...neededFiles] = NEEDED_FILES;
const context = vm.createContext({
OuterArray: Array,
Object: Object,
TestingColorLayers: new Set(loadCSV("Assets/Female3DCG/LayerNames.csv", 2).map(i => i[0])),
TestingColorGroups: new Set(loadCSV("Assets/Female3DCG/ColorGroups.csv", 2).map(i => i[0])),
});
vm.runInContext(fs.readFileSync(BASE_PATH + commonFile, { encoding: "utf-8" }), context, {
filename: commonFile,
});
// Only patch `CommonGet` after loading `Common`, lest our monkey patch will be overriden again
context.CommonGet = (file, callback) => {
const data = fs.readFileSync(`../../${file}`, "utf8");
const obj = {
status: 200,
responseText: data,
};
callback.bind(obj)(obj);
};
for (const file of neededFiles) {
vm.runInContext(fs.readFileSync(BASE_PATH + file, { encoding: "utf-8" }), context, {
filename: file,
});
}
/** @type {AssetGroupDefinition[]} */
const AssetFemale3DCG = sanitizeVMOutput(context.AssetFemale3DCG);
/** @type {ExtendedItemMainConfig} */
const AssetFemale3DCGExtended = sanitizeVMOutput(context.AssetFemale3DCGExtended);
/** @type {TestingStruct<string>[]} */
const missingColorLayers = sanitizeVMOutput(context.TestingMisingColorLayers);
/** @type {TestingStruct<string>[]} */
const missingColorGroups = sanitizeVMOutput(context.TestingMisingColorGroups);
/** @type {TestingStruct<string[]>[]} */
const invalidColorDefaults = sanitizeVMOutput(context.TestingInvalidDefaultColor);
/** @type {Record<string, ModularItemData>} */
const ModularItemDataLookup = context.TestingModularItemDataLookup;
/** @type {Record<string, TypedItemData>} */
const TypedItemDataLookup = context.TestingTypedItemDataLookup;
/** @type {Record<string, VibratingItemData>} */
const VibratingItemDataLookup = context.TestingVibratingItemDataLookup;
/** @type {Record<string, TextItemData>} */
const TextItemDataLookup = context.TestingVariableHeightItemDataLookup;
/** @type {Record<string, VariableHeightData>} */
const VariableHeightItemDataLookup = context.TestingTextItemDataLookup;
/** @type {Asset[]} */
const assetList = context.Asset;
/** @type {Record<AssetPoseCategory, Set<AssetPoseName>>} */
const poseMap = context.TestingPoseMap;
if (!Array.isArray(AssetFemale3DCG)) {
error("AssetFemale3DCG not found");
return;
}
const assetDescriptions = loadCSV("Assets/Female3DCG/Female3DCG.csv", 3);
const dialogArray = loadCSV("Screens/Character/Player/Dialog_Player.csv", 6);
// No further checks if initial data load failed
if (common.localError) {
return;
}
// Arrays of type-validated groups and assets
/** @type {AssetGroupDefinition[]} */
const Groups = [];
/** @type {Record<string, AssetDefinition[]>} */
const Assets = {};
// Check all groups
for (const Group of AssetFemale3DCG) {
common.localError = false;
Groups.push(Group);
/** @type {AssetDefinition[]} */
const GroupAssets = (Assets[Group.Group] = []);
// Check all assets in groups
for (const Asset of Group.Asset) {
if (typeof Asset === "string") {
GroupAssets.push({
Name: Asset
});
continue;
}
common.localError = false;
// Check any extended item config
if (Asset.Extended) {
const groupConfig = AssetFemale3DCGExtended[Group.Group] || {};
const assetConfig = groupConfig[Asset.Name];
if (assetConfig) {
const archetype = assetConfig.Archetype;
if (archetype) {
const propList = ["AllowEffect", "AllowBlock", "AllowHide", "AllowHideItem", "AllowType", "AllowLockType"];
for (const name of propList) {
if (Asset[name] !== undefined) {
error(`${Group.Group}:${Asset.Name}: Archetypical extended items must NOT set ${name}`);
}
}
}
}
}
if (Array.isArray(Asset.Layer)) {
if (Asset.Layer.length === 0) {
error(`${Group.Group}:${Asset.Name}: Asset must contain at least one layer when explicitly specified`);
}
for (const layerDef of Asset.Layer) {
// Check for conflicting layer properties
if (Array.isArray(layerDef.HideForAttribute) && Array.isArray(layerDef.ShowForAttribute)) {
for (const attribute of layerDef.HideForAttribute) {
if (layerDef.ShowForAttribute.includes(attribute)) {
error(`Layer ${Group.Group}:${Asset.Name}:${layerDef.Name}: Attribute "${attribute}" should NOT appear in both HideForAttribute and ShowForAttribute`);
}
}
}
}
}
if (!common.localError) {
GroupAssets.push(Asset);
}
}
}
if (common.globalError) {
console.log("WARNING: Type errors detected, skipping other checks");
return;
}
// Validate description order
{
let group = "";
for (let line = 0; line < assetDescriptions.length; line++) {
if (assetDescriptions[line][1] === "") {
group = assetDescriptions[line][0];
} else if (assetDescriptions[line][0] !== group) {
error(
`Asset descriptions line ${line + 1} not under it's matching group! ` +
`(${assetDescriptions[line][0]}:${assetDescriptions[line][1]} is in ${group} group)`
);
}
}
}
// Check all type-valid groups for specific data
for (const Group of Groups) {
// Description name
const descriptionIndex = assetDescriptions.findIndex(d => d[0] === Group.Group && d[1] === "");
if (descriptionIndex < 0) {
error(`No description for group "${Group.Group}"`);
} else {
assetDescriptions.splice(descriptionIndex, 1);
}
// Check all type-valid assets for specific data
for (const Asset of Assets[Group.Group]) {
// Description name
const descriptionIndexAsset = assetDescriptions.findIndex(d => d[0] === Group.Group && d[1] === Asset.Name);
if (descriptionIndexAsset < 0) {
error(`No description for asset "${Group.Group}:${Asset.Name}"`);
} else {
assetDescriptions.splice(descriptionIndexAsset, 1);
}
}
}
// Check for extra descriptions
for (const desc of assetDescriptions) {
error(`Unused Asset/Group description: ${desc.join(",")}`);
}
testDynamicGroupName(AssetFemale3DCG);
testExtendedItemDialog(AssetFemale3DCGExtended, dialogArray);
testColorGroups(missingColorGroups);
testColorLayers(missingColorLayers);
testModuleOptionLength(AssetFemale3DCGExtended);
testDefaultColor(invalidColorDefaults);
testModularItemPropertyDisjoint(ModularItemDataLookup);
testExtendedItemButtonImage({
modular: ModularItemDataLookup,
text: TextItemDataLookup,
typed: TypedItemDataLookup,
variableheight: VariableHeightItemDataLookup,
vibrating: VibratingItemDataLookup,
});
testAssetLayerNames(assetList);
testAssetAndExtendedPropertyIsDisjoint({
modular: ModularItemDataLookup,
text: TextItemDataLookup,
typed: TypedItemDataLookup,
variableheight: VariableHeightItemDataLookup,
vibrating: VibratingItemDataLookup,
});
testAllowTypes({ ...TypedItemDataLookup, ...ModularItemDataLookup });
testAllowModuleTypes(ModularItemDataLookup);
testCopyLayerColor(assetList);
testExtendedPrerequisite({
modular: ModularItemDataLookup,
text: TextItemDataLookup,
typed: TypedItemDataLookup,
variableheight: VariableHeightItemDataLookup,
vibrating: VibratingItemDataLookup,
});
testActivePose(assetList, poseMap);
testHasPreview(assetList);
testAssetHasPNG(assetList, {
modular: ModularItemDataLookup,
typed: TypedItemDataLookup,
});
})();