Index: ps/trunk/binaries/data/mods/mod/gui/modmod/modmod.js =================================================================== --- ps/trunk/binaries/data/mods/mod/gui/modmod/modmod.js +++ ps/trunk/binaries/data/mods/mod/gui/modmod/modmod.js @@ -1,36 +1,46 @@ /** - * Contains JS objects defined by the mod JSON files available. - * @example - *{ - * "public": + * @file This GUI page displays all available mods and allows the player to enabled and launch a set of compatible mods. + */ + +/** + * A mod is defined by a mod.json file, for example * { * "name": "0ad", * "version": "0.0.16", * "label": "0 A.D. - Empires Ascendant", - * "url": "http://wildfregames.com/", + * "url": "http://wildfiregames.com/", * "description": "A free, open-source, historical RTS game.", * "dependencies": [] - * }, - * "foldername2": { + * } + * + * Or: + * { * "name": "mod2", * "label": "Mod 2", * "version": "1.1", * "description": "", * "dependencies": ["0ad<=0.0.16", "rote"] * } - *} + * + * A mod is identified by the directory name. + * A mod must define the "name", "version", "label", "description" and "dependencies" property. + * The "url" property is optional. + * + * The property "name" can consist alphanumeric characters, underscore and dash. + * The name is used for version comparison of mod dependencies. + * The property "version" may only contain numbers and up to two periods. + * The property "label" is a human-readable name of the mod. + * The property "description" is a human-readable summary of the features of the mod. + * The property "url" is reference to a website about the mod. + * The property "dependencies" is an array of strings. Each string is either a modname or a mod version comparison. + * A mod version comparison is a modname, followed by an operator (=, <, >, <= or >=), followed by a mod version. + * This allows mods to express upwards and downwards compatibility. */ -var g_Mods = {}; /** - * Every mod needs to define these properties. + * Mod definitions loaded from the files, including invalid mods. */ -var g_RequiredProperties = ["name", "label", "description", "dependencies", "version"]; - -/** - * Version checks in mod dependencies can use these operators. - */ -var g_CompareVersion = /(<=|>=|<|>|=)/; +var g_Mods = {}; /** * Folder names of all mods that are or can be launched. @@ -45,27 +55,30 @@ function init() { loadMods(); + loadEnabledMods(); + validateMods(); initGUIFilters(); } function loadMods() { - let mods = Engine.GetAvailableMods(); - - for (let folder in mods) - if (g_RequiredProperties.every(prop => mods[folder][prop] !== undefined)) - g_Mods[folder] = mods[folder]; - else - warn("Skipping mod '" + mod + "' which does not define '" + property + "'."); - + g_Mods = Engine.GetAvailableMods(); translateObjectKeys(g_Mods, ["label", "description"]); - deepfreeze(g_Mods); +} +function loadEnabledMods() +{ g_ModsEnabled = Engine.ConfigDB_GetValue("user", "mod.enabledmods").split(/\s+/).filter(folder => !!g_Mods[folder]); g_ModsDisabled = Object.keys(g_Mods).filter(folder => g_ModsEnabled.indexOf(folder) == -1); } +function validateMods() +{ + for (let folder in g_Mods) + validateMod(folder, g_Mods[folder], true); +} + function initGUIFilters() { Engine.GetGUIObjectByName("negateFilter").checked = false; @@ -267,7 +280,7 @@ */ function isDependencyMet(dependency) { - let operator = dependency.match(g_CompareVersion); + let operator = dependency.match(g_RegExpComparison); let [name, version] = operator ? dependency.split(operator[0]) : [dependency, undefined]; return g_ModsEnabled.some(folder => @@ -292,8 +305,6 @@ for (let i = 0; i < Math.min(versionList1.length, versionList2.length); ++i) { let diff = +versionList1[i] - +versionList2[i]; - if (isNaN(diff)) - continue; if (gt && diff > 0 || lt && diff < 0) return true; @@ -318,7 +329,7 @@ { let dependencies = {}; for (let folder of g_ModsEnabled) - dependencies[folder] = g_Mods[folder].dependencies.map(d => d.split(g_CompareVersion)[0]); + dependencies[folder] = g_Mods[folder].dependencies.map(d => d.split(g_RegExpComparison)[0]); g_ModsEnabled.sort((folder1, folder2) => dependencies[folder1].indexOf(g_Mods[folder2].name) != -1 ? 1 : Index: ps/trunk/binaries/data/mods/mod/gui/modmod/validatemod.js =================================================================== --- ps/trunk/binaries/data/mods/mod/gui/modmod/validatemod.js +++ ps/trunk/binaries/data/mods/mod/gui/modmod/validatemod.js @@ -0,0 +1,153 @@ +const g_ModProperties = { + // example: "0ad" + "name": { + "required": true, + "type": "string", + "validate": validateName + }, + // example: "0.0.23" + "version": { + "required": true, + "type": "string", + "validate": validateVersion + }, + // example: ["0ad<=0.0.16", "rote"] + "dependencies": { + "required": true, + "type": "object", + "validate": validateDependencies + }, + // example: "0 A.D. - Empires Ascendant" + "label": { + "require": true, + "type": "string", + "validate": validateLabel + }, + // example: "A free, open-source, historical RTS game." + "description": { + "required": true, + "type": "string" + }, + // example: "http://wildfiregames.com/" + "url": { + "required": false, + "type": "string" + } +}; + +/** + * Tests if the string only contains alphanumeric characters and _ - + */ +const g_RegExpName = /[a-z0-9\-\_]+/i; + +/** + * Tests if the version string consists only of numbers and at most two periods. + */ +const g_RegExpVersion= /[0-9]+(\.[0-9]+){0,2}/; + +/** + * Version checks in mod dependencies can use these operators. + */ +const g_RegExpComparisonOperator = /(<=|>=|<|>|=)/; + +/** + * Tests if a dependency compares a mod version against another, for instance "0ad<=0.0.16". + */ +const g_RegExpComparison = globalRegExp(new RegExp(g_RegExpName.source + g_RegExpComparisonOperator.source + g_RegExpVersion.source)); + +/** + * The label may not be empty. + */ +const g_RegExpLabel = /.*\S.*/; + +function globalRegExp(regexp) +{ + return new RegExp("^" + regexp.source + "$"); +} + +/** + * Returns whether the mod defines all required properties and whether all properties are valid. + * Shows a notification if not. + */ +function validateMod(folder, modData, notify) +{ + let valid = true; + + for (let propertyName in g_ModProperties) + { + let property = g_ModProperties[propertyName]; + + if (modData[propertyName] === undefined) + { + if (!property.required) + continue; + + if (notify) + warn("Mod '" + folder + "' does not define '" + propertyName + "'!"); + + valid = false; + } + + if (typeof modData[propertyName] != property.type) + { + if (notify) + warn(propertyName + " in mod '" + folder + "' is not of the type '" + property.type + "'!"); + + valid = false; + + continue; + } + + if (property.validate && !property.validate(folder, modData, notify)) + valid = false; + } + + return valid; +} + +function validateName(folder, modData, notify) +{ + let valid = modData.name.match(globalRegExp(g_RegExpName)); + + if (!valid && notify) + warn("mod name of " + folder + " may only contain alphanumeric characters, but found '" + modData.name + "'!"); + + return valid; +} + +function validateVersion(folder, modData, notify) +{ + let valid = modData.version.match(globalRegExp(g_RegExpVersion)); + + if (!valid && notify) + warn("mod name of " + folder + " may only contain numbers and at most 2 periods, but found '" + modData.version + "'!"); + + return valid; +} + +function validateDependencies(folder, modData, notify) +{ + let valid = true; + + for (let dependency of modData.dependencies) + { + valid = valid && ( + dependency.match(globalRegExp(g_RegExpVersion)) || + dependency.match(globalRegExp(g_RegExpComparison))); + + if (!valid && notify) + warn("mod folder " + folder + " requires an invalid dependency '" + dependency + "'!"); + } + + return valid; +} + +function validateLabel(folder, modData, notify) +{ + let valid = modData.label.match(g_RegExpLabel); + + if (!valid && notify) + warn("mod label of " + folder + " may not be empty!"); + + return valid; +} Index: ps/trunk/binaries/data/mods/mod/gui/modmod/validatemod_test.js =================================================================== --- ps/trunk/binaries/data/mods/mod/gui/modmod/validatemod_test.js +++ ps/trunk/binaries/data/mods/mod/gui/modmod/validatemod_test.js @@ -0,0 +1,86 @@ +const g_ValidTestMods = { + "public": { + "name": "0ad", + "version": "0.0.23", + "label": "0 A.D. Empires Ascendant", + "url": "play0ad.com", + "description": "A free, open-source, historical RTS game.", + "dependencies": [] + }, + "tm": { + "name": "terra_magna", + "version": "0.0.22", + "label": "0 A.D. Terra Magna", + "url": "forum.wildfiregames.com", + "description": "Adds various civilizations to 0 A.D.", + "dependencies": ["0ad=0.0.23"] + }, + "mil": { + "name": "millenniumad", + "version": "0.0.22", + "label": "0 A.D. Medieval Extension", + "url": "forum.wildfiregames.com", + "description": "Adds medieval content like civilizations + maps.", + "dependencies": ["0ad=0.0.23"] + } +}; + +const g_TestModsInvalid = { + "broken1": { + "name": "name may not contain whitespace", + "version": "1", + "label": "1", + "description": "", + "dependencies": [] + }, + "broken2": { + "name": "broken2", + "version": "0.0.2.1", + "label": "2", + "description": "it has too many dots in the version", + "dependencies": [] + }, + "broken3": { + "name": "broken3", + "version": "broken3", + "label": "3", + "description": "version numbers must be numeric", + "dependencies": [] + }, + "broken4": { + "name": "broken4", + "version": "4", + "label": "4", + "description": "dependencies must be mod names or valid comparisons", + "dependencies": ["mod version=3"] + }, + "broken5": { + "name": "broken5", + "version": "5", + "label": "5", + "description": "names in mod dependencies may not contain whitespace either", + "dependencies": ["mod version"] + }, + "broken6": { + "name": "broken6", + "version": "6", + "label": "6", + "description": "should have used =", + "dependencies": ["mod==3"] + }, + "broken7": { + "name": "broken7", + "version": "7", + "label": "", + "description": "label may not be empty", + "dependencies": [] + } +}; + +for (let folder in g_ValidTestMods) + if (!validateMod(folder, g_ValidTestMods[folder], false)) + throw new Error("Valid mod '" + folder + "' should have passed the test."); + +for (let folder in g_TestModsInvalid) + if (validateMod(folder, g_TestModsInvalid[folder], false)) + throw new Error("Invalid mod '" + folder + "' should not have passed the test.");