mirror of
https://gitgud.io/BondageProjects/Bondage-College.git
synced 2025-04-25 17:59:34 +00:00
1540 lines
49 KiB
JavaScript
1540 lines
49 KiB
JavaScript
import vm from "vm";
|
|
import fs from "fs";
|
|
|
|
import { NEEDED_FILES, BASE_PATH, error, loadCSV, fromEntries, enumerate, keys, entries, errorState } from "./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]));
|
|
let first = true;
|
|
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) {
|
|
if (first) {
|
|
first = false;
|
|
console.error('\nERROR: Missing dialog key(s) in "BondageClub/Assets/Female3DCG/AssetStrings.csv":');
|
|
}
|
|
|
|
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");
|
|
return;
|
|
} else if (!missingGroups.length) {
|
|
return;
|
|
} else {
|
|
console.error('\nERROR: Missing color group(s) in "BondageClub/Assets/Female3DCG/ColorGroups.csv":');
|
|
}
|
|
|
|
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;
|
|
} else if (!missingLayers.length) {
|
|
return;
|
|
} else {
|
|
console.error('\nERROR: Missing color layer(s) in "BondageClub/Assets/Female3DCG/LayerNames.csv":');
|
|
}
|
|
|
|
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", "TypeRecord"]);
|
|
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)) {
|
|
// FIXME: just because I want AssetCheck to shut up.
|
|
// This asset has both a clothing and restraint, but they have mismatching types
|
|
// Yet, they use DynamicGroupName, which causes one to look up the files of the other
|
|
// and the simpler one warns because it doesn't expect so many options.
|
|
if (imagePath.includes("ItemHead/RubberMask")) continue;
|
|
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}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate that all types referenced in {@link AssetLayer.AllowTypes} actually exist.
|
|
* @param {Record<ExtendedArchetype, Record<string, AssetArchetypeData>>} dataSuperRecord
|
|
*/
|
|
function testAllowTypes(dataSuperRecord) {
|
|
/** @type {(data: AssetArchetypeData, typeSet: Set<PartialType>) => Set<PartialType>} */
|
|
function _dfs(data, typeSet) {
|
|
/** @type {AssetArchetypeData[]} */
|
|
const subscreenData = [];
|
|
switch (data.archetype) {
|
|
case "typed":
|
|
case "vibrating": {
|
|
/** @type {(VibratingItemOption | TypedItemOption)[]} */
|
|
const options = data.options;
|
|
for (const [i, option] of enumerate(options)) {
|
|
typeSet.add(`${data.name}${i}`);
|
|
if (option.ArchetypeData) {
|
|
subscreenData.push(option.ArchetypeData);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "modular": {
|
|
const options = data.modules.flatMap(m => m.Options);
|
|
for (const option of options) {
|
|
typeSet.add(option.Name);
|
|
if (option.ArchetypeData) {
|
|
subscreenData.push(option.ArchetypeData);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
subscreenData.forEach(d => _dfs(d, typeSet));
|
|
return typeSet;
|
|
}
|
|
|
|
for (const dataRecord of Object.values(dataSuperRecord)) {
|
|
for (const data of Object.values(dataRecord)) {
|
|
if (data.parentOption != null || data.archetype === "text") {
|
|
continue;
|
|
}
|
|
|
|
const asset = data.asset;
|
|
const allowedTypes = _dfs(data, new Set());
|
|
for (const layer of asset.Layer) {
|
|
if (!layer.AllowTypes) {
|
|
continue;
|
|
}
|
|
|
|
const invalid = keys(layer.AllowTypes.TypeToID).filter(k => !allowedTypes.has(k));
|
|
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 {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}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Copy of the definition in Asset.js */
|
|
const PoseType = /** @type {const} */({
|
|
/**
|
|
* Ensures that the asset is hidden for a specific pose.
|
|
* Supercedes the old `HideForPose` property.
|
|
*/
|
|
HIDE: "Hide",
|
|
/**
|
|
* Ensures that the default (pose-agnostic) asset used for a particular pose.
|
|
* Supercedes the old `AllowPose` property.
|
|
*/
|
|
DEFAULT: "",
|
|
});
|
|
|
|
/**
|
|
* 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) && asset.PoseMapping[p] !== PoseType.DEFAULT && asset.PoseMapping[p] !== PoseType.HIDE);
|
|
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}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// TODO: Run this over all extended item options
|
|
if (!asset.SetPose) {
|
|
error(`${asset.Group.Name}:${asset.Name}: Cannot define "AllowActivePose" without "SetPose"`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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}"`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Asset} asset
|
|
* @param {Record<ExtendedArchetype, Record<string, AssetArchetypeData>>} extendedDataRecords
|
|
* @returns {Record<string, ExtendedItemOptionUnion[]>}
|
|
*/
|
|
function getExtendedOptions(asset, extendedDataRecords) {
|
|
/** @type {(data: AssetArchetypeData, optionRecord: Record<string, ExtendedItemOptionUnion[]>) => void} */
|
|
function _dfs(data, optionRecord) {
|
|
/** @type {ExtendedItemOptionUnion[]} */
|
|
let options = [];
|
|
switch (data.archetype) {
|
|
case "typed":
|
|
case "vibrating":
|
|
options = data.options;
|
|
optionRecord[data.name] = data.options;
|
|
break;
|
|
case "modular":
|
|
options = data.modules.flatMap(m => m.Options);
|
|
data.modules.forEach(m => optionRecord[m.Key] = m.Options);
|
|
break;
|
|
}
|
|
for (const o of options) {
|
|
if (o.ArchetypeData) {
|
|
_dfs(o.ArchetypeData, optionRecord);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @type {Record<string, ExtendedItemOptionUnion[]>} */
|
|
const ret = {};
|
|
const rootData = extendedDataRecords[/** @type {ExtendedArchetype} */(asset.Archetype)]?.[`${asset.Group.Name}${asset.Name}`];
|
|
if (rootData) {
|
|
_dfs(rootData, ret);
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
/**
|
|
* @param {AssetPoseMapping} poseMapping
|
|
* @param {Record<AssetPoseName, Pose>} poseRecord
|
|
* @returns {AssetPoseMapping}
|
|
*/
|
|
function padPoseMapping(poseMapping, poseRecord) {
|
|
const categories = new Set(keys(poseMapping).map(p => poseRecord[p].Category));
|
|
if (categories.has("BodyUpper") || categories.has("BodyLower")) {
|
|
categories.add("BodyFull");
|
|
}
|
|
|
|
return fromEntries(Array.from(categories).flatMap(c => {
|
|
return Object.values(poseRecord).filter(p => {
|
|
return p.Category === c;
|
|
}).map(p => {
|
|
return [p.Name, poseMapping[p.Name] ?? PoseType.DEFAULT];
|
|
});
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Check that all assets have the expected .png images.
|
|
* @param {readonly Asset[]} assets
|
|
* @param {Record<ExtendedArchetype, Record<string, AssetArchetypeData>>} extendedDataRecords
|
|
* @param {Record<AssetPoseName, Pose>} poseRecord
|
|
*/
|
|
function testAssetHasPNG(assets, extendedDataRecords, poseRecord) {
|
|
const expressionGroups = new Set(assets.filter(a => a.Group.AllowExpression).map(a => a.Group.Name));
|
|
const assetGroups = /** @type {Record<AssetGroupName, AssetGroup>} */({});
|
|
const assetGroupMap = /** @type {Record<AssetGroupName, { F: Set<string>, M: Set<string>, FM: Set<string> }>} */({});
|
|
for (const asset of assets) {
|
|
const gender = asset.Gender ?? "FM";
|
|
if (!(asset.Group.Name in assetGroupMap)) {
|
|
assetGroupMap[asset.Group.Name] = { "F": new Set(), "M": new Set(), "FM": new Set() };
|
|
}
|
|
|
|
if (gender === "FM") {
|
|
assetGroupMap[asset.Group.Name].F.add(asset.Name);
|
|
assetGroupMap[asset.Group.Name].M.add(asset.Name);
|
|
assetGroupMap[asset.Group.Name].FM.add(asset.Name);
|
|
} else {
|
|
assetGroupMap[asset.Group.Name][gender].add(asset.Name);
|
|
assetGroupMap[asset.Group.Name].FM.add(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 poseMapping = asset.AllowActivePose ? layer.PoseMapping : padPoseMapping(layer.PoseMapping, poseRecord);
|
|
const poses = new Set(Object.values(poseMapping).filter(p => p !== PoseType.HIDE));
|
|
if (poses.size === 0) {
|
|
poses.add(PoseType.DEFAULT);
|
|
}
|
|
|
|
// Combinations involving archetypical items
|
|
/** @type {[type: PartialType | null, poses: Iterable<AssetPoseName | PoseType>][]} */
|
|
let layerTypes = [[null, poses]];
|
|
const optionsRecord = getExtendedOptions(asset, extendedDataRecords);
|
|
if (asset.Name === "SlaveCollar") {
|
|
layerTypes = [];
|
|
for (let i = 0; i <= 21; i++) {
|
|
layerTypes.push([`noarch${i}`, poses]);
|
|
}
|
|
} else if (layer.CreateLayerTypes.length || layer.AllowTypes) {
|
|
/** @type {PartialType[]} */
|
|
const partialLayerTypes = [];
|
|
/** @type {ExtendedItemOption[]} */
|
|
let options = [];
|
|
if (layer.CreateLayerTypes.length) {
|
|
const allTypes = layer.AllowTypes?.AllTypes ?? {};
|
|
options = layer.CreateLayerTypes.flatMap(screenKey => {
|
|
if (!optionsRecord[screenKey]) {
|
|
error(`${asset.Group.Name}:${asset.Name}:${layer.Name}: missing extended options. Possibly a missing CopyConfig directive?`);
|
|
return /** @type {never} */({});
|
|
}
|
|
return optionsRecord[screenKey].map((o, i) => {
|
|
if (allTypes[screenKey]?.has(i) ?? true) {
|
|
partialLayerTypes.push(`${screenKey}${i}`);
|
|
return o;
|
|
} else {
|
|
return /** @type {never} */(null);
|
|
}
|
|
}).filter(i => i != null);
|
|
});
|
|
} else if (layer.AllowTypes) {
|
|
// Filter out `undefined` in case someone messes up `layer.AllowTypes`
|
|
options = Object.entries(layer.AllowTypes.AllTypes).flatMap(([screenKey, indexSet]) => {
|
|
return Array.from(indexSet).map(i => {
|
|
partialLayerTypes.push(`${screenKey}${i}`);
|
|
return optionsRecord[screenKey][i];
|
|
}).filter(o => o !== undefined);
|
|
});
|
|
}
|
|
|
|
if (options.length > 0) {
|
|
layerTypes = options.map((o, i) => {
|
|
const optionPoses = o.Property?.AllowActivePose ?? asset.AllowActivePose ?? keys(poseMapping);
|
|
let mappedPoses = optionPoses.map(p => poseMapping[p] ?? PoseType.DEFAULT);
|
|
if (mappedPoses.length && mappedPoses.every(p => p === PoseType.HIDE)) {
|
|
mappedPoses = [];
|
|
} else {
|
|
mappedPoses = mappedPoses.filter(p => p !== PoseType.HIDE && p !== PoseType.DEFAULT);
|
|
if (mappedPoses.length === 0) {
|
|
mappedPoses.push(PoseType.DEFAULT);
|
|
}
|
|
}
|
|
return [layer.CreateLayerTypes.length ? partialLayerTypes[i] : null, mappedPoses];
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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 expressions
|
|
/** @type {readonly (null | ExpressionName)[]} */
|
|
let expressions = [null, ...(layer.Asset.AllowExpression ?? layer.Asset.Group.AllowExpression ?? [])];
|
|
if (
|
|
!(expressionGroups.has(asset.Group.Name) || layer.MirrorExpression)
|
|
|| (asset.Group.Name === "Pussy" && asset.Name.startsWith("Pussy"))
|
|
) {
|
|
expressions = [null];
|
|
}
|
|
|
|
for (const iter of [colors, expressions, layerTypes]) {
|
|
if (Array.from(/** @type {Iterable<any>} */(iter)).length === 0) {
|
|
error(`${asset.Group.Name}:${asset.Name}:${layer.Name}: Internal error; zero-length list encountered!`);
|
|
}
|
|
}
|
|
|
|
const root = `${BASE_PATH}Assets/${asset.Group.Family}/${asset.DynamicGroupName}/`;
|
|
for (const color of colors) {
|
|
for (const expression of expressions) {
|
|
for (const [type, poseList] of layerTypes) {
|
|
for (const pose of poseList) {
|
|
/** @type {undefined | "" | AssetGroupName} */
|
|
const parentGroup = layer.ParentGroup[pose] ?? layer.ParentGroup[PoseType.DEFAULT];
|
|
const parentNames = !parentGroup ? new Set([null]) : assetGroupMap[parentGroup][asset.Gender ?? "FM"];
|
|
if (parentNames.size === 0) {
|
|
error(`${asset.Group.Name}:${asset.Name}:${layer.Name}: Internal error; zero-length list encountered!`);
|
|
}
|
|
|
|
for (const parent of parentNames) {
|
|
const path = (
|
|
root
|
|
+ (pose ? `${pose}/` : "")
|
|
+ (expression ? `${expression}/` : "")
|
|
);
|
|
const fileParts = [];
|
|
if (asset.Name) fileParts.push(asset.Name);
|
|
if (parent) fileParts.push(parent);
|
|
if (type) fileParts.push(type);
|
|
if (color) fileParts.push(color);
|
|
if (layer.Name) fileParts.push(layer.Name);
|
|
const file = path + fileParts.join("_") + ".png";
|
|
if (!fs.existsSync(file)) {
|
|
error(`${asset.Group.Name}:${asset.Name}:${layer.Name}: Missing asset "${file}"`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check that {@link Asset.SetPose} contains only one pose per category.
|
|
* @param {readonly Asset[]} assets
|
|
* @param {readonly Pose[]} poses
|
|
*/
|
|
function testSetPose(assets, poses) {
|
|
const poseRecord = fromEntries(poses.map(p => [p.Name, p]));
|
|
for (const asset of assets) {
|
|
const setPose = asset.SetPose;
|
|
if (!setPose) {
|
|
continue;
|
|
}
|
|
|
|
/** @type {Partial<Record<AssetPoseCategory, { n: number, poses: Pose[] }>> }} */
|
|
const categories = {};
|
|
for (const poseName of setPose) {
|
|
const pose = poseRecord[poseName];
|
|
const rec = categories[pose.Category];
|
|
if (rec) {
|
|
rec.n += 1;
|
|
rec.poses.push(pose);
|
|
} else {
|
|
categories[pose.Category] = { n: 1, poses: [pose] };
|
|
}
|
|
}
|
|
|
|
if ("BodyFull" in categories && ("BodyUpper" in categories || "BodyLower" in categories)) {
|
|
const invalidPoses = [
|
|
...(categories.BodyFull?.poses ?? []),
|
|
...(categories.BodyUpper?.poses ?? []),
|
|
...(categories.BodyLower?.poses ?? []),
|
|
].map(p => p.Name);
|
|
error(
|
|
`${asset.Group.Name}:${asset.Name}: Cannot have both BodyFull and BodyUpper/BodyLower poses specified in SetPose`
|
|
+ `; offending members: ${invalidPoses}`,
|
|
);
|
|
}
|
|
|
|
const duplicates = Object.values(categories).filter(pose => pose.n > 1).flatMap(pose => pose.poses.map(p => p.Name));
|
|
if (duplicates.length > 0) {
|
|
error(
|
|
`${asset.Group.Name}:${asset.Name}: Cannot have both multiple poses of the same category specified in SetPose`
|
|
+ `; offending members: ${duplicates}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check that asset groups referenced in `AssetFemale3DCGExtended` match those in `AssetFemale3DCG`.
|
|
* @param {ExtendedItemMainConfig} extendedItemRecords
|
|
* @param {readonly Asset[]} assets
|
|
*/
|
|
function testExtendedItemGroupName(extendedItemRecords, assets) {
|
|
/** @type {Record<string, AssetGroupName[]>} */
|
|
const assetMapping = {};
|
|
for (const asset of assets) {
|
|
assetMapping[asset.Name] ??= [];
|
|
assetMapping[asset.Name].push(asset.Group.Name);
|
|
}
|
|
|
|
for (const [group, groupConfig] of entries(extendedItemRecords)) {
|
|
for (const asset of keys(groupConfig)) {
|
|
const validGroups = assetMapping[asset];
|
|
if (!validGroups.includes(group)) {
|
|
error(
|
|
`${asset}: Extended asset declares an invalid group ("${group}") in AssetFemale3DCGExtended; `
|
|
+ `valid available groups: ${validGroups.sort()}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check that the console did not log any runtime warnings.
|
|
* @param {Record<"warn" | "error", [src: string, ...rest: unknown[]][]>} logOutput
|
|
*/
|
|
function testConsoleLog(logOutput) {
|
|
const logs = [
|
|
...logOutput.warn.filter(i => i.length > 0),
|
|
...logOutput.error.filter(i => i.length > 0),
|
|
];
|
|
if (logs.length > 0) {
|
|
const stringLogs = logs.map(([source, ...args]) => {
|
|
return source ? `${source}: ${args.join(", ")}` : args.join(", ");
|
|
}).join("\n\t");
|
|
error(`Found ${logs.length} unexpected console warnings:\n\t${stringLogs}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test that appropriate callbacks exist for:
|
|
* * {@link Asset.DynamicAfterDraw}
|
|
* * {@link Asset.DynamicBeforeDraw}
|
|
* * {@link Asset.DynamicScriptDraw}
|
|
* @param {readonly Asset[]} assets
|
|
* @param {Record<string, unknown>} window
|
|
*/
|
|
function testDrawCallbacks(assets, window) {
|
|
const propertyNames = /** @type {const} */({
|
|
AfterDraw: "DynamicAfterDraw",
|
|
BeforeDraw: "DynamicBeforeDraw",
|
|
ScriptDraw: "DynamicScriptDraw",
|
|
});
|
|
|
|
for (const asset of assets) {
|
|
for (const [funcSuffix, fieldName] of Object.entries(propertyNames)) {
|
|
const funcName = `Assets${asset.Group.Name}${asset.Name}${funcSuffix}`;
|
|
const func = window[funcName];
|
|
if (!asset[fieldName] && func !== undefined) {
|
|
error(`${asset.Group.Name}:${asset.Name}: cannot define "${funcName}" without specifying "Asset.${fieldName}"`);
|
|
} else if (asset[fieldName] && typeof func !== "function") {
|
|
error(`${asset.Group.Name}:${asset.Name}: cannot specify "Asset.${fieldName}" without defining "${funcName}"`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {readonly Asset[]} assets
|
|
* @param {Record<ExtendedArchetype, Record<string, AssetArchetypeData>>} extendedDataRecords
|
|
*/
|
|
function testSelfBlock(assets, extendedDataRecords) {
|
|
for (const asset of assets) {
|
|
/** @type {readonly AssetGroupName[]} */
|
|
const blocks = asset.Block ?? [];
|
|
if (blocks.includes(asset.Group.Name)) {
|
|
error(`${asset.Group.Name}:${asset.Name}: item must not block its own group`);
|
|
}
|
|
|
|
/** @type {Record<string, ExtendedItemOption[]>} */
|
|
const extendedOptions = getExtendedOptions(asset, extendedDataRecords);
|
|
for (const [extendedName, options] of Object.entries(extendedOptions)) {
|
|
/** @type {readonly AssetGroupName[]} */
|
|
const extendedBlocks = options.flatMap(o => o.Property?.Block ?? []);
|
|
if (extendedBlocks.includes(asset.Group.Name)) {
|
|
error(`${asset.Group.Name}:${asset.Name}:${extendedName} extended item must not block its own group`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/** @type {Record<"warn" | "error", [src: string, ...rest: unknown[]][]>} */
|
|
const logOutput = {
|
|
warn: [],
|
|
error: [],
|
|
};
|
|
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])),
|
|
console: {
|
|
...console,
|
|
warn: function warn(...args) {
|
|
// Identify the function responsible for issuing the warning via the stack trace
|
|
const trace = (new Error()).stack;
|
|
const source = trace ? trace.split("\n")[2].trim() : "";
|
|
logOutput.warn.push([source, ...args]);
|
|
},
|
|
// eslint-disable-next-line no-shadow
|
|
error: function error(...args) {
|
|
const trace = (new Error()).stack;
|
|
const source = trace ? trace.split("\n")[2].trim() : "";
|
|
logOutput.error.push([source, ...args]);
|
|
},
|
|
},
|
|
});
|
|
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.TestingTextItemDataLookup;
|
|
/** @type {Record<string, VariableHeightData>} */
|
|
const VariableHeightItemDataLookup = context.TestingVariableHeightItemDataLookup;
|
|
/** @type {Record<string, NoArchItemData>} */
|
|
const NoArchItemDataLookup = context.TestingNoArchItemDataLookup;
|
|
/** @type {Asset[]} */
|
|
const assetList = context.Asset;
|
|
/** @type {AssetGroup[]} */
|
|
const groupList = context.AssetGroup;
|
|
/** @type {Record<AssetPoseCategory, Set<AssetPoseName>>} */
|
|
const poseMap = context.TestingPoseMap;
|
|
/** @type {Pose[]} */
|
|
const pose = context.PoseFemale3DCG;
|
|
const poseRecord = fromEntries(pose.map(p => [p.Name, p]));
|
|
|
|
const extendedItemSuperRecord = {
|
|
modular: ModularItemDataLookup,
|
|
text: TextItemDataLookup,
|
|
typed: TypedItemDataLookup,
|
|
variableheight: VariableHeightItemDataLookup,
|
|
vibrating: VibratingItemDataLookup,
|
|
noarch: NoArchItemDataLookup,
|
|
};
|
|
|
|
if (!Array.isArray(AssetFemale3DCG)) {
|
|
error("AssetFemale3DCG not found");
|
|
return;
|
|
}
|
|
|
|
const assetDescriptions = loadCSV("Assets/Female3DCG/Female3DCG.csv", 3);
|
|
const dialogArray = loadCSV("Assets/Female3DCG/AssetStrings.csv", 2);
|
|
|
|
// No further checks if initial data load failed
|
|
if (errorState.local) {
|
|
return;
|
|
}
|
|
|
|
// Check all groups
|
|
for (const Group of AssetFemale3DCG) {
|
|
errorState.local = false;
|
|
|
|
// Check all assets in groups
|
|
for (const Asset of Group.Asset) {
|
|
if (typeof Asset === "string") {
|
|
continue;
|
|
}
|
|
errorState.local = 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 && Asset.Name !== "SlaveCollar") {
|
|
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 (errorState.global) {
|
|
console.log("WARNING: Type errors detected, skipping other checks");
|
|
return;
|
|
}
|
|
|
|
// Check all type-valid groups for specific data
|
|
let descriptionFirst = true;
|
|
for (const Group of groupList) {
|
|
// Description name
|
|
const descriptionIndex = assetDescriptions.findIndex(d => d[0] === Group.Name && d[1] === "");
|
|
if (descriptionIndex < 0) {
|
|
if (descriptionFirst) {
|
|
descriptionFirst = false;
|
|
console.error('\nERROR: Missing asset- and/or group-description(s) in "BondageClub/Assets/Female3DCG/Female3DCG.csv":');
|
|
}
|
|
error(`No description for group "${Group.Name}"`);
|
|
} else {
|
|
assetDescriptions.splice(descriptionIndex, 1);
|
|
}
|
|
|
|
// Check all type-valid assets for specific data
|
|
for (const Asset of Group.Asset) {
|
|
if (Asset.DynamicGroupName !== Group.Name) {
|
|
continue;
|
|
}
|
|
if (Asset.Name === "" && !Group.AllowCustomize) {
|
|
// Special case for the "static" body groups (arms and hands)
|
|
continue;
|
|
}
|
|
|
|
// Description name
|
|
const descriptionIndexAsset = assetDescriptions.findIndex(d => d[0] === Group.Name && d[1] === Asset.Name);
|
|
if (descriptionIndexAsset < 0) {
|
|
if (descriptionFirst) {
|
|
descriptionFirst = false;
|
|
console.error('\nERROR: Missing asset- and/or group-description(s) in "BondageClub/Assets/Female3DCG/Female3DCG.csv":');
|
|
}
|
|
error(`No description for asset "${Group.Name}:${Asset.Name}"`);
|
|
} else {
|
|
assetDescriptions.splice(descriptionIndexAsset, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for extra descriptions
|
|
for (const desc of assetDescriptions) {
|
|
error(`Unused Asset/Group description: ${desc.join(",")}`);
|
|
}
|
|
|
|
testConsoleLog(logOutput);
|
|
testExtendedItemGroupName(AssetFemale3DCGExtended, assetList);
|
|
testDrawCallbacks(assetList, context);
|
|
testDynamicGroupName(AssetFemale3DCG);
|
|
testExtendedItemDialog(AssetFemale3DCGExtended, dialogArray);
|
|
testColorGroups(missingColorGroups);
|
|
testColorLayers(missingColorLayers);
|
|
testModuleOptionLength(AssetFemale3DCGExtended);
|
|
testDefaultColor(invalidColorDefaults);
|
|
testModularItemPropertyDisjoint(ModularItemDataLookup);
|
|
testExtendedItemButtonImage(extendedItemSuperRecord);
|
|
testAssetLayerNames(assetList);
|
|
testAssetAndExtendedPropertyIsDisjoint(extendedItemSuperRecord);
|
|
testAllowTypes(extendedItemSuperRecord);
|
|
testCopyLayerColor(assetList);
|
|
testExtendedPrerequisite(extendedItemSuperRecord);
|
|
testActivePose(assetList, poseMap);
|
|
testHasPreview(assetList);
|
|
testAssetHasPNG(assetList, extendedItemSuperRecord, poseRecord);
|
|
testSetPose(assetList, pose);
|
|
testSelfBlock(assetList, extendedItemSuperRecord);
|
|
})();
|