Define, document, validate and test validation of the format of mod.json files.

The mod "name" may only consist of alphanumeric characters, underscore
and dash, because it should be used for mod dependency checks.
Drop two special characters from the "version" property.

Differential Revision: https://code.wildfiregames.com/D1093
Res #4427, d3ce5289b6
Reviewed By: Itms
This was SVN commit r20637.
This commit is contained in:
elexis 2017-12-10 16:13:18 +00:00
parent a82175b580
commit 71121b8a89
3 changed files with 281 additions and 31 deletions

View file

@ -1,37 +1,47 @@
/**
* 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.
*/
/**
* Mod definitions loaded from the files, including invalid mods.
*/
var g_Mods = {};
/**
* Every mod needs to define these properties.
*/
var g_RequiredProperties = ["name", "label", "description", "dependencies", "version"];
/**
* Version checks in mod dependencies can use these operators.
*/
var g_CompareVersion = /(<=|>=|<|>|=)/;
/**
* Folder names of all mods that are or can be launched.
*/
@ -45,27 +55,30 @@ var g_ColorDependenciesNotMet = "255 100 100";
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 areDependenciesMet(folder)
*/
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 @@ function versionSatisfied(version1, operator, version2)
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 @@ function sortEnabledMods()
{
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 :

View file

@ -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;
}

View file

@ -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.");