Module:GameData/doc: Difference between revisions

From Melvor Idle
(Add MAX_TRADER_STOCK_INCREASE to townKeys for next update)
(Update code for localization & various other bits)
Line 9: Line 9:
{{SpoilerBox|color=default|title=Code|text=<pre>// TODO:
{{SpoilerBox|color=default|title=Code|text=<pre>// TODO:
// Handle modifications portion of data packages
// Handle modifications portion of data packages
// Use actual descriptions as per language data
class Wiki {
class Wiki {
    constructor() {
constructor() {
        this.namespaces = {
this.debugMode = false;
            melvorD: { displayName: "Demo", url: "https://" + location.hostname + "/assets/data/melvorDemo.json" },
this.prettyPrint = false;
            melvorF: { displayName: "Full Version", url: "https://" + location.hostname + "/assets/data/melvorFull.json" },
this.namespaces = {
            melvorTotH: { displayName: "Throne of the Herald", url: "https://" + location.hostname + "/assets/data/melvorTotH.json" }
melvorD: { displayName: "Demo", url: "https://" + location.hostname + "/assets/data/melvorDemo.json" },
        };
melvorF: { displayName: "Full Version", url: "https://" + location.hostname + "/assets/data/melvorFull.json" },
        // Check all required namespaces are registered, as there are still some bits of data extracted from in-game rather than the data packages
melvorTotH: { displayName: "Throne of the Herald", url: "https://" + location.hostname + "/assets/data/melvorTotH.json" }
        Object.keys(this.namespaces).forEach((nsID) => {
};
            const nsTest = game.registeredNamespaces.getNamespace(nsID);
// Check all required namespaces are registered, as there are still some bits of data extracted from in-game rather than the data packages
            if (nsTest === undefined) {
Object.keys(this.namespaces).forEach((nsID) => {
                throw new Error(`Namespace ${ nsID } (${ this.namespaces[nsID].displayName }) is not registered - Ensure you are signed in and have the expansion.`);
const nsTest = game.registeredNamespaces.getNamespace(nsID);
            }
if (nsTest === undefined) {
        });
throw new Error(`Namespace ${ nsID } (${ this.namespaces[nsID].displayName }) is not registered - Ensure you are signed in and have the expansion.`);
}
});


        this.packData = {};
this.packData = {};
        this.gameData = {};
this.gameData = {};
        this.skillDataInit = {};
this.skillDataInit = {};
        this.dataPropFilters = {
};
            // Specifies rules for properties of entities (items, monsters, etc.) which
async getWikiData() {
            // will be removed as they are unused in the wiki & would otherwise bloat
if (!isLoaded) {
            // the data.
throw new Error('Game must be loaded into a character first');
            // Format: property: ruleFunction(entityType, entity)
}
            //      where ruleFunction is a function returning true if the property is to
for (const nsIdx in Object.keys(this.namespaces)) {
            //      be retained, false otherwise
const ns = Object.keys(this.namespaces)[nsIdx];
            media: function(entityType, entity) { return false; },
const dataURL = this.namespaces[ns].url;
            altMedia: function(entityType, entity) { return false; },
console.log(`URL: ${ dataURL }`);
            markMedia: function(entityType, entity) { return false; },
const dataPackage = await this.getDataPackage(dataURL);
            icon: function(entityType, entity) { return false; },
if (dataPackage.namespace === undefined) {
            barStyle: function(entityType, entity) { return false; }, // See: melvorD:Compost
throw new Error(`Data package has no namespace: ${ dataURL }`);
            buttonStyle: function(entityType, entity) { return false; },
}
            descriptionGenerator: function(entityType, entity) { return false; },
else if (dataPackage.data === undefined) {
            containerID: function(entityType, entity) { return false; },
throw new Error(`Data package has no data: ${ dataURL }`);
            headerBgClass: function(entityType, entity) { return false; },
}
            textClass: function(entityType, entity) { return false; },
console.log(`Obtained data for namespace ${ dataPackage.namespace }, ${ JSON.stringify(dataPackage.data).length.toLocaleString() } bytes`);
            btnClass: function(entityType, entity) { return false; },
this.processDataPackage(dataPackage);
            golbinRaidExclusive: function(entityType, entity) { return entity.golbinRaidExclusive; },
console.log(`After transformation: ${ JSON.stringify(dataPackage.data).length.toLocaleString() } bytes`);
            ignoreCompletion: function(entityType, entity) { return entity.ignoreCompletion; },
}
            obtainFromItemLog: function(entityType, entity) { return entity.obtainFromItemLog; },
// All data packages should now be within this.gameData
            validSlots: function(entityType, entity) { return entity.validSlots.length > 0; },
}
            occupiesSlots: function(entityType, entity) { return entity.occupiesSlots.length > 0; },
getGameVersion() {
            equipRequirements: function(entityType, entity) { return entity.equipRequirements.length > 0; },
const fileDOM = document.querySelector('#sidebar ul.nav-main');
            equipmentStats: function(entityType, entity) { return entity.equipmentStats.length > 0; },
let fileVer = "Unknown";
            tier: function(entityType, entity) {
if (fileDOM !== null && fileDOM.dataset !== undefined) {
                if (entityType === 'items') {
fileVer = fileDOM.dataset.fileVersion;
                    return entity.tier !== 'none';
}
                }
return gameVersion + ' (' + fileVer + ')';
                else {
}
                    return true;
async printWikiData() {
                }
if (!isLoaded) {
            }
throw new Error('Game must be loaded into a character first');
        };
}
        this.dataPropTransforms = {
if (Object.keys(this.packData).length < Object.keys(this.namespaces).length) {
            // Specifies rules for transforming values of entity properties
// Need to retrieve game data first
            // Format: property: ruleFunction(entityType, entity)
const result = await this.getWikiData();
            //      where ruleFunction is a function returning the transformed value
}
            //      to be used in place of the original value
let dataObjText;
            langHint: function(ns, entityType, entity) {
if (this.prettyPrint) {
                let localID = ''
dataObjText = JSON.stringify(this.gameData, undefined, '\t');
                if (entity.id.indexOf(':') > 0) {
}
                    localID = entity.id.split(':').pop();
else {
                }
dataObjText = JSON.stringify(this.gameData);
                else {
}
                    localID = entity.id
dataObjText = dataObjText.replace(/\'/g, "\\\'");
                }
dataObjText = dataObjText.replace(/\\\"/g, "\\\\\"");
                return getLangString(entity.category, localID);
            },
            equipmentStats: function(ns, entityType, entity) {
                const newStats = {};
                entity.forEach((stat) => {
                    if (newStats[stat.key] === undefined) {
                        newStats[stat.key] = stat.value;
                    }
                    else {
                        newStats[stat.key] += stat.value;
                    }
                });
                return newStats;
            },
            altSpells: function(ns, entityType, entity) {
                if (entityType !== 'skillData') {
                    return entity;
                }
                else {
                    const newData = structuredClone(entity);
                    newData.forEach((i) => {
                        i.spellBook = 'altMagic';
                    });
                    return newData;
                }
            },
            attacks: function(ns, entityType, entity) {
                if (entityType !== 'attacks') {
                    return entity;
                }
                else {
                    entity.forEach((attDef) => {
                        const nsAttID = ns + ':' + attDef.id;
                        const att = game.specialAttacks.getObjectByID(nsAttID);
                        attDef.description = att.description;
                    });
                    return entity;
                }
            }
        };
        this.dataPropTransforms.langCustomDescription = this.dataPropTransforms.langHint;
    };
    async getWikiData() {
        if (!isLoaded) {
            throw new Error('Game must be loaded into a character first');
        }
        for (const nsIdx in Object.keys(this.namespaces)) {
            const ns = Object.keys(this.namespaces)[nsIdx];
            const dataURL = this.namespaces[ns].url;
            console.log(`URL: ${ dataURL }`);
            const dataPackage = await this.getDataPackage(dataURL);
            if (dataPackage.namespace === undefined) {
                throw new Error(`Data package has no namespace: ${ dataURL }`);
            }
            else if (dataPackage.data === undefined) {
                throw new Error(`Data package has no data: ${ dataURL }`);
            }
            console.log(`Obtained data for namespace ${ dataPackage.namespace }, ${ JSON.stringify(dataPackage.data).length.toLocaleString() } bytes`);
            this.processDataPackage(dataPackage);
            console.log(`After transformation: ${ JSON.stringify(dataPackage.data).length.toLocaleString() } bytes`);
        }
        // All data packages should now be within this.gameData
    }
    getGameVersion() {
        const fileDOM = document.querySelector('#sidebar ul.nav-main');
        let fileVer = "Unknown";
        if (fileDOM !== null && fileDOM.dataset !== undefined) {
            fileVer = fileDOM.dataset.fileVersion;
        }
        return gameVersion + ' (' + fileVer + ')';
    }
    async printWikiData() {
        if (!isLoaded) {
            throw new Error('Game must be loaded into a character first');
        }
        if (Object.keys(this.packData).length < Object.keys(this.namespaces).length) {
            // Need to retrieve game data first
            const result = await this.getWikiData();
        }
        let dataObjText = JSON.stringify(this.gameData);
        dataObjText = dataObjText.replace(/\'/g, "\\\'");
        dataObjText = dataObjText.replace(/\\\"/g, "\\\\\"");


        let dataText = '-- Version: ' + this.getGameVersion();
let dataText = '-- Version: ' + this.getGameVersion();
        dataText += "\r\n\r\nlocal gameData = mw.text.jsonDecode('";
dataText += "\r\n\r\nlocal gameData = mw.text.jsonDecode('";
        dataText += dataObjText;
dataText += dataObjText;
        dataText += "')\r\n\r\nreturn gameData";
dataText += "')\r\n\r\nreturn gameData";
        console.log(dataText);
console.log(dataText);
    }
}
    async getDataPackage(url) {
async getDataPackage(url) {
        // Based largely on Game.fetchAndRegisterDataPackage()
// Based largely on Game.fetchAndRegisterDataPackage()
        const headers = new Headers();
const headers = new Headers();
        headers.append('Content-Type', 'application/json');
headers.append('Content-Type', 'application/json');
        return await fetch(url, {
return await fetch(url, {
            method: 'GET',
method: 'GET',
            headers
headers
        }).then(function(response) {
}).then(function(response) {
            if (!response.ok) {
if (!response.ok) {
                throw new Error(`Couldn't fetch data package from URL: ${ url }`);
throw new Error(`Couldn't fetch data package from URL: ${ url }`);
            }
}
            return response.json();
return response.json();
        });
});
    }
}
    processDataPackage(dataPackage) {
processDataPackage(dataPackage) {
        // Transforms the raw data from data packages in various ways, then
// Transforms the raw data from data packages in various ways, then
        // consolidates into this.packData & this.gameData
// consolidates into this.packData & this.gameData
        const ns = dataPackage.namespace;
const ns = dataPackage.namespace;
        const packData = dataPackage.data;
const packData = dataPackage.data;


        this.transformDataPackage(dataPackage);
this.transformDataPackage(dataPackage);
        this.packData[dataPackage.namespace] = dataPackage;
this.packData[dataPackage.namespace] = dataPackage;
        this.registerDataPackage(dataPackage.namespace);
this.registerDataPackage(dataPackage.namespace);
    }
}
    transformDataPackage(dataPackage) {
transformDataPackage(dataPackage) {
        // Takes a raw data package and performs various manipulations
// Takes a raw data package and performs various manipulations
        const ns = dataPackage.namespace;
const ns = dataPackage.namespace;
        const packData = dataPackage.data;
const packData = dataPackage.data;


        Object.keys(packData).forEach((categoryName) => {
Object.keys(packData).forEach((categoryName) => {
            switch(categoryName) {
switch(categoryName) {
                case 'bankSortOrder':
case 'bankSortOrder':
                case 'steamAchievements':
case 'pages':
                    // This data serves no purpose for the wiki and only serves to bloat
case 'steamAchievements':
                    // the data, so simply delete it
case 'tutorialStageOrder':
                    delete packData[categoryName];
case 'tutorialStages':
                    break;
// This data serves no purpose for the wiki and only serves to bloat
                default:
// the data, so simply delete it
                    this.transformDataNode(ns, categoryName, packData, categoryName);
delete packData[categoryName];
                    break;
break;
            }
default:
        });
this.transformDataNode(ns, categoryName, packData, categoryName);
    }
break;
    transformDataNode(ns, categoryName, parentNode, nodeKey) {
}
        let dataNode = parentNode[nodeKey];
});
        const transformFunc = this.dataPropTransforms[nodeKey];
}
        if (transformFunc !== undefined) {
transformDataNode(ns, categoryName, parentNode, nodeKey) {
            // A transformation function is defined for this node
let dataNode = parentNode[nodeKey];
            parentNode[nodeKey] = transformFunc(ns, categoryName, dataNode);
const transformedValue = this.transformProperty(categoryName, dataNode, nodeKey, ns);
            dataNode = parentNode[nodeKey];
if (transformedValue !== undefined) {
        }
// A transformed value exists for this node
        if (Array.isArray(dataNode)) {
parentNode[nodeKey] = transformedValue;
            // Recursive call to ensure all data is transformed, regardless of its depth
dataNode = parentNode[nodeKey];
            dataNode.forEach((entity, idx) => this.transformDataNode(ns, categoryName, dataNode, idx));
}
        }
if (Array.isArray(dataNode)) {
        else if (typeof dataNode === 'object' && dataNode !== null) {
// Recursive call to ensure all data is transformed, regardless of its depth
            // Iterate properties of object, checking if each should be deleted or transformed
dataNode.forEach((entity, idx) => this.transformDataNode(ns, categoryName, dataNode, idx));
            Object.keys(dataNode).forEach((key) => {
}
                // Check if property is to be deleted or not
else if (typeof dataNode === 'object' && dataNode !== null) {
                const filterFunc = this.dataPropFilters[key];
// Iterate properties of object, checking if each should be deleted or transformed
                if (filterFunc !== undefined && !filterFunc(categoryName, dataNode)) {
Object.keys(dataNode).forEach((key) => {
                    delete dataNode[key];
// Check if property is to be deleted or not
                }
if (this.isPropertyFiltered(categoryName, dataNode, key)) {
                else if (typeof dataNode[key] === "object" && dataNode[key] !== null) {
delete dataNode[key];
                    // If an object (either an array or key/value store) is within the current
}
                    // object then we must traverse this too
else if (typeof dataNode[key] === "object" && dataNode[key] !== null) {
                    this.transformDataNode(ns, categoryName, dataNode, key);
// If an object (either an array or key/value store) is within the current
                }
// object then we must traverse this too
                else {
this.transformDataNode(ns, categoryName, dataNode, key);
                    // Transform property, if a transformation is defined below
}
                    switch(key) {
else {
                        case 'id':
// Transform property, if a transformation is defined below
                            // Add namespace to ID if it isn't already
switch(key) {
                            dataNode[key] = this.getNamespacedID(ns, dataNode[key]);
case 'id':
                            break;
// Add namespace to ID if it isn't already
                        case 'name':
dataNode[key] = this.getNamespacedID(ns, dataNode[key]);
                            // Some items have underscores in their names, replace with spaces
break;
                            if (categoryName == 'items') {
}
                                dataNode[key] = dataNode[key].replaceAll('_', ' ');
}
                            }
});
                            break;
}
                    }
// Apply localization, except for if this is skill data. That is handled separately below
                }
if (categoryName !== 'skillData' && categoryName == nodeKey) {
            });
this.langApply(parentNode, nodeKey, false);
        }
}
        // Special case for skillData so that certain values initialized when the various Skill
        // classes are initialized may be added here also
        if ((categoryName === 'skillData') && dataNode.skillID !== undefined && dataNode.data !== undefined && !this.skillDataInit[dataNode.skillID]) {
            // We are currently at the topmost level of a skill object
            const gameSkill = game.skills.registeredObjects.get(dataNode.skillID);
            if (gameSkill !== undefined) {
                dataNode.data.name = getLangString('SKILL_NAME', this.getLocalID(dataNode.skillID));


                if (gameSkill.milestones !== undefined && dataNode.data.milestoneCount === undefined) {
// Special case for skillData so that certain values initialized when the various Skill
                    dataNode.data.milestoneCount = gameSkill.milestones.length;
// classes are initialized may be added here also
                }
if ((categoryName === 'skillData') && dataNode.skillID !== undefined && dataNode.data !== undefined) {
                // For every skill with mastery, add mastery checkpoint descriptions
// We are currently at the topmost level of a skill object
                if (gameSkill instanceof SkillWithMastery && dataNode.data.masteryTokenID !== undefined && dataNode.data.masteryCheckpoints === undefined) {
if (!this.skillDataInit[dataNode.skillID]) {
                    const localID = this.getLocalID(dataNode.skillID);
const gameSkill = game.skills.getObjectByID(dataNode.skillID);
                    dataNode.data.baseMasteryPoolCap = gameSkill.baseMasteryPoolCap;
if (gameSkill !== undefined) {
                    dataNode.data.masteryCheckpoints = [];
if (gameSkill.milestones !== undefined && dataNode.data.milestoneCount === undefined) {
                    masteryCheckpoints.forEach((pct, idx) => {
dataNode.data.milestoneCount = gameSkill.milestones.length;
                        dataNode.data.masteryCheckpoints[idx] = getLangString('MASTERY_CHECKPOINT', `${ localID }_${ idx }`);
}
                    });
// For every skill with mastery, add mastery checkpoint descriptions
                }
if (gameSkill instanceof SkillWithMastery && dataNode.data.masteryTokenID !== undefined && dataNode.data.masteryCheckpoints === undefined) {
const localID = this.getLocalID(dataNode.skillID);
dataNode.data.baseMasteryPoolCap = gameSkill.baseMasteryPoolCap;
dataNode.data.masteryCheckpoints = [];
masteryCheckpoints.forEach((pct, idx) => {
dataNode.data.masteryCheckpoints[idx] = getLangString('MASTERY_CHECKPOINT', `${ localID }_${ idx }`);
});
}


                // Import other attributes varying by skill
// Import other attributes varying by skill
                let importKeys = [];
let importKeys = [];
                switch(dataNode.skillID) {
switch(dataNode.skillID) {
                    case 'melvorD:Firemaking':
case 'melvorD:Firemaking':
                        importKeys = [
importKeys = [
                            'baseAshChance',
'baseAshChance',
                            'baseStardustChance',
'baseStardustChance',
                            'baseCharcoalChance'
'baseCharcoalChance'
                        ];
];
                        break;
break;
                    case 'melvorD:Mining':
case 'melvorD:Mining':
                        importKeys = [
importKeys = [
                            'baseInterval',
'baseInterval',
                            'baseRockHP',
'baseRockHP',
                            'passiveRegenInterval'
'passiveRegenInterval'
                        ];
];
                        dataNode.baseGemChance = 1;
dataNode.data.baseGemChance = 1;
                        break;
dataNode.data.rockTypes = loadedLangJson.MINING_TYPE;
                    case 'melvorD:Smithing':
break;
                    case 'melvorD:Fletching':
case 'melvorD:Smithing':
                    case 'melvorD:Crafting':
case 'melvorD:Fletching':
                    case 'melvorD:Runecrafting':
case 'melvorD:Crafting':
                    case 'melvorD:Herblore':
case 'melvorD:Runecrafting':
                        importKeys = [
case 'melvorD:Herblore':
                            'baseInterval'
importKeys = [
                        ];
'baseInterval'
                        break;
];
                    case 'melvorD:Thieving':
break;
                        importKeys = [
case 'melvorD:Thieving':
                            'baseInterval',
importKeys = [
                            'baseStunInterval',
'baseInterval',
                            'itemChance',
'baseStunInterval',
                            'baseAreaUniqueChance'
'itemChance',
                        ];
'baseAreaUniqueChance'
                        break;
];
                    case 'melvorD:Agility':
break;
                        importKeys = [
case 'melvorD:Agility':
                            'obstacleUnlockLevels'
importKeys = [
                        ];
'obstacleUnlockLevels'
                        break;
];
                    case 'melvorD:Summoning':
break;
                        importKeys = [
case 'melvorD:Summoning':
                            'baseInterval'
importKeys = [
                        ];
'baseInterval'
                        const sumKeys = [
];
                            'recipeGPCost',
const sumKeys = [
                            'markLevels'   
'recipeGPCost',
                        ];
'markLevels'   
                        sumKeys.forEach((k) => dataNode.data[k] = Summoning[k]);
];
                        break;
sumKeys.forEach((k) => dataNode.data[k] = Summoning[k]);
                    case 'melvorD:Astrology':
break;
                        // Astrology has a number of values stored outside of gameSkill
case 'melvorD:Astrology':
                        const astKeys = [
// Astrology has a number of values stored outside of gameSkill
                            'standardModifierLevels',
const astKeys = [
                            'uniqueModifierLevels',
'standardModifierLevels',
                            'standardModifierCosts',
'uniqueModifierLevels',
                            'uniqueModifierCosts',
'standardModifierCosts',
                            'baseStardustChance',
'uniqueModifierCosts',
                            'baseGoldenStardustChance',
'baseStardustChance',
                            'baseInterval'
'baseGoldenStardustChance',
                        ];
'baseInterval'
                        astKeys.forEach((k) => dataNode.data[k] = Astrology[k]);
];
                        break;
astKeys.forEach((k) => dataNode.data[k] = Astrology[k]);
                    case 'melvorD:Township':
break;
                        // Remap a number of keys from their in-game names
case 'melvorD:Township':
                        const townKeys = [
// Remap a number of keys from their in-game names
                            {'from': 'TICK_LENGTH', 'to': 'tickLength'},
const townKeys = [
                            {'from': 'MAX_TOWN_SIZE', 'to': 'maxTownSize'},
{'from': 'TICK_LENGTH', 'to': 'tickLength'},
                            {'from': 'SECTION_SIZE', 'to': 'sectionSize'},
{'from': 'MAX_TOWN_SIZE', 'to': 'maxTownSize'},
                            {'from': 'INITIAL_CITIZEN_COUNT', 'to': 'initialCitizenCount'},
{'from': 'SECTION_SIZE', 'to': 'sectionSize'},
                            {'from': 'MIN_WORKER_AGE', 'to': 'minWorkerAge'},
{'from': 'INITIAL_CITIZEN_COUNT', 'to': 'initialCitizenCount'},
                            {'from': 'MAX_WORKER_AGE', 'to': 'maxWorkerAge'},
{'from': 'MIN_WORKER_AGE', 'to': 'minWorkerAge'},
                            {'from': 'AGE_OF_DEATH', 'to': 'ageOfDeath'},
{'from': 'MAX_WORKER_AGE', 'to': 'maxWorkerAge'},
                            {'from': 'MIN_MIGRATION_AGE', 'to': 'minMigrationAge'},
{'from': 'AGE_OF_DEATH', 'to': 'ageOfDeath'},
                            {'from': 'MAX_MIGRATION_AGE', 'to': 'maxMigrationAge'},
{'from': 'MIN_MIGRATION_AGE', 'to': 'minMigrationAge'},
                            {'from': 'BASE_TAX_RATE', 'to': 'baseTaxRate'},
{'from': 'MAX_MIGRATION_AGE', 'to': 'maxMigrationAge'},
                            {'from': 'EDUCATION_PER_CITIZEN', 'to': 'educationPerCitizen'},
{'from': 'BASE_TAX_RATE', 'to': 'baseTaxRate'},
                            {'from': 'HAPPINESS_PER_CITIZEN', 'to': 'happinessPerCitizen'},
{'from': 'EDUCATION_PER_CITIZEN', 'to': 'educationPerCitizen'},
                            {'from': 'CITIZEN_FOOD_USAGE', 'to': 'citizenFoodUsage'},
{'from': 'HAPPINESS_PER_CITIZEN', 'to': 'happinessPerCitizen'},
                            {'from': 'POPULATION_REQUIRED_FOR_BIRTH', 'to': 'populationRequiredForBirth'},
{'from': 'CITIZEN_FOOD_USAGE', 'to': 'citizenFoodUsage'},
                            {'from': 'BASE_STORAGE', 'to': 'baseStorage'},
{'from': 'POPULATION_REQUIRED_FOR_BIRTH', 'to': 'populationRequiredForBirth'},
                            {'from': 'WORSHIP_CHECKPOINTS', 'to': 'worshipCheckpoints'},
{'from': 'BASE_STORAGE', 'to': 'baseStorage'},
                            {'from': 'MAX_WORSHIP', 'to': 'maxWorship'},
{'from': 'WORSHIP_CHECKPOINTS', 'to': 'worshipCheckpoints'},
                            {'from': 'populationForTier', 'to': 'populationForTier'},
{'from': 'MAX_WORSHIP', 'to': 'maxWorship'},
                            {'from': 'MAX_TRADER_STOCK_INCREASE', 'to': 'maxTraderStockIncrease'},
{'from': 'populationForTier', 'to': 'populationForTier'},
                        ];
{'from': 'MAX_TRADER_STOCK_INCREASE', 'to': 'maxTraderStockIncrease'},
                        townKeys.forEach((k) => dataNode.data[k.to] = gameSkill[k.from]);
];
                        break;
townKeys.forEach((k) => dataNode.data[k.to] = gameSkill[k.from]);
                }
// Add task categories & localization of name
                if (importKeys.length > 0) {
const taskCategories = Array.from(new Set(gameSkill.tasks.tasks.allObjects.map((t) => t.category)));
                    importKeys.forEach((k) => dataNode.data[k] = gameSkill[k]);
dataNode.data.taskCategories = taskCategories.map((i) => ({ id: i, name: gameSkill.tasks.getTownshipTaskCategoryName(i)}));
                }
break;
            }
}
            this.skillDataInit[dataNode.skillID] = true;
if (importKeys.length > 0) {
        }
importKeys.forEach((k) => dataNode.data[k] = gameSkill[k]);
    }
}
    registerDataPackage(namespace) {
}
        // Consolidates the data package identified by namespace with existing data within
this.skillDataInit[dataNode.skillID] = true;
        // this.gameData
}
        const packData = this.packData[namespace].data;
// Appy localization (skills)
        if (packData === undefined) {
this.langApply(parentNode, nodeKey, true);
            throw new Error(`Couldn't find data for package ${ namespace }`);
}
        }
}
        // Add data within the game but outside of data packs
registerDataPackage(namespace) {
        this.registerNonPackData();
// Consolidates the data package identified by namespace with existing data within
        // Consolidate data
// this.gameData
        Object.keys(packData).forEach((categoryName) => {
const packData = this.packData[namespace].data;
            let categoryData = packData[categoryName];
if (packData === undefined) {
            // Some data is adjusted before combining - do this here
throw new Error(`Couldn't find data for package ${ namespace }`);
            if (['combatAreas', 'dungeons', 'slayerAreas'].includes(categoryName)) {
}
                // Add area type to each area object
// Add data within the game but outside of data packs
                const areaTypes = {
this.registerNonPackData();
                    'combatAreas': 'combatArea',
// Consolidate data
                    'dungeons': 'dungeon',
Object.keys(packData).forEach((categoryName) => {
                    'slayerAreas': 'slayerArea'
let categoryData = packData[categoryName];
                }
// Some data is adjusted before combining - do this here
                const areaType = areaTypes[categoryName];
if (['combatAreas', 'dungeons', 'slayerAreas'].includes(categoryName)) {
                const newData = structuredClone(categoryData);
// Add area type to each area object
                newData.forEach((x) => x.type = areaType);
const areaTypes = {
                categoryData = newData;
'combatAreas': 'combatArea',
            }
'dungeons': 'dungeon',
            else if (['ancientSpells', 'archaicSpells', 'auroraSpells', 'curseSpells', 'standardSpells'].includes(categoryName)) {
'slayerAreas': 'slayerArea'
                // For spell books, add the spell type to each spell object.
}
                // Alt Magic spells are handled elsewhere, as they are within a skill object
const areaType = areaTypes[categoryName];
                const spellType = categoryName.replace('Spells', '');
const newData = structuredClone(categoryData);
                const newData = structuredClone(categoryData);
newData.forEach((x) => x.type = areaType);
                newData.forEach((x) => x.spellBook = spellType);
categoryData = newData;
                categoryData = newData;
}
            }
else if (['ancientSpells', 'archaicSpells', 'auroraSpells', 'curseSpells', 'standardSpells'].includes(categoryName)) {
            else if (categoryName === 'golbinRaid') {
// For spell books, add the spell type to each spell object.
// Alt Magic spells are handled elsewhere, as they are within a skill object
const spellType = categoryName.replace('Spells', '');
const newData = structuredClone(categoryData);
newData.forEach((x) => x.spellBook = spellType);
categoryData = newData;
}
else if (categoryName === 'golbinRaid') {


            }
}
            // Data must be pushed into the consoldiated data, rules for vary
// Data must be pushed into the consoldiated data, rules for vary
            // depending on the category in question
// depending on the category in question
            switch(categoryName) {
switch(categoryName) {
                case 'ancientSpells':
case 'ancientSpells':
                case 'archaicSpells':
case 'archaicSpells':
                case 'attackStyles':
case 'attackStyles':
                case 'attacks':
case 'attacks':
                case 'auroraSpells':
case 'auroraSpells':
                case 'combatAreas':
case 'combatAreas':
                case 'combatEvents':
case 'combatEvents':
                case 'combatPassives':
case 'combatPassives':
                case 'curseSpells':
case 'curseSpells':
                case 'dungeons':
case 'dungeons':
                case 'gamemodes':
case 'gamemodes':
                case 'itemEffects':
case 'itemEffects':
                case 'itemSynergies':
case 'itemSynergies':
                case 'itemUpgrades':
case 'itemUpgrades':
                case 'itmMonsters':
case 'itmMonsters':
                case 'items':
case 'items':
                case 'lore':
case 'lore':
                case 'monsters':
case 'monsters':
                case 'pages':
case 'pages':
                case 'pets':
case 'pets':
                case 'prayers':
case 'prayers':
                case 'randomGems':
case 'randomGems':
                case 'randomSuperiorGems':
case 'randomSuperiorGems':
                case 'shopCategories':
case 'shopCategories':
                case 'shopPurchases':
case 'shopPurchases':
                case 'shopUpgradeChains':
case 'shopUpgradeChains':
                case 'slayerAreas':
case 'slayerAreas':
                case 'stackingEffects':
case 'stackingEffects':
                case 'standardSpells':
case 'standardSpells':
                case 'steamAchievements':
case 'steamAchievements':
                case 'tutorialStages':
case 'tutorialStages':
                case 'spiderLairMonsters':
case 'spiderLairMonsters':
                    // Plain old push to the end of the array
// Plain old push to the end of the array
                    if (this.gameData[categoryName] === undefined) {
if (this.gameData[categoryName] === undefined) {
                        // Category doesn't exist yet in consolidated data, so create it
// Category doesn't exist yet in consolidated data, so create it
                        this.gameData[categoryName] = categoryData;
this.gameData[categoryName] = categoryData;
                    }
}
                    else {
else {
                        this.gameData[categoryName].push(...categoryData);
this.gameData[categoryName].push(...categoryData);
                    }
}
                    break;
break;
                case 'combatAreaDisplayOrder':
case 'combatAreaDisplayOrder':
                case 'dungeonDisplayOrder':
case 'dungeonDisplayOrder':
                case 'shopCategoryOrder':
case 'shopCategoryOrder':
                case 'shopDisplayOrder':
case 'shopDisplayOrder':
                case 'slayerAreaDisplayOrder':
case 'slayerAreaDisplayOrder':
                case 'tutorialStageOrder':
case 'tutorialStageOrder':
                    // Elements are inserted at a particular index, controlled by rules
// Elements are inserted at a particular index, controlled by rules
                    // specified within the data package
// specified within the data package
this.gameData[categoryName] = this.combineOrderedData(this.gameData[categoryName], categoryData);
this.gameData[categoryName] = this.combineOrderedData(this.gameData[categoryName], categoryData);
                    break;
break;
                case 'golbinRaid':
case 'golbinRaid':
                    // Properties contain unordered arrays that need to be combined
// Properties contain unordered arrays that need to be combined
                    if (this.gameData[categoryName] === undefined) {
if (this.gameData[categoryName] === undefined) {
                        this.gameData[categoryName] = categoryData;
this.gameData[categoryName] = categoryData;
                        this.gameData.golbinRaid.possibleModifiers = RaidManager.possibleModifiers;
this.gameData.golbinRaid.possibleModifiers = RaidManager.possibleModifiers;
                    }
}
                    else {
else {
                        Object.keys(categoryData).forEach((dataKey) => {
Object.keys(categoryData).forEach((dataKey) => {
                            if ((this.gameData[categoryName][dataKey] === undefined) || !Array.isArray(this.gameData[categoryName][dataKey])) {
if ((this.gameData[categoryName][dataKey] === undefined) || !Array.isArray(this.gameData[categoryName][dataKey])) {
                                // Property is undefined or isn't an array
// Property is undefined or isn't an array
                                this.gameData[categoryName][dataKey] = categoryData[dataKey];
this.gameData[categoryName][dataKey] = categoryData[dataKey];
                            }  
}  
                            else {
else {
                                // Property is an array
// Property is an array
                                this.gameData[categoryName][dataKey].push(...categoryData[dataKey]);
this.gameData[categoryName][dataKey].push(...categoryData[dataKey]);
                            }
}
                        });
});
                    }
}
                    break;
break;
                case 'skillData':
case 'skillData':
                    // Contains nested objects
// Contains nested objects
                    if (this.gameData[categoryName] === undefined) {
if (this.gameData[categoryName] === undefined) {
                        this.gameData[categoryName] = [];
this.gameData[categoryName] = [];
                    }
}
// Find the appropriate skill object and combine properties with that
// Find the appropriate skill object and combine properties with that
categoryData.forEach((skillData) => {
categoryData.forEach((skillData) => {
Line 506: Line 432:
});
});
});
});
                    break;
break;
                default:
default:
                    console.warn(`Skipping unknown category while registering data package: ${ categoryName }`);
console.warn(`Skipping unknown category while registering data package: ${ categoryName }`);
                    break;
break;
            }
}
        });
});
    }
}
    registerNonPackData() {
registerNonPackData() {
        // Some data resides outside of packages. Add any such data to this.gameData within this function
// Some data resides outside of packages. Add any such data to this.gameData within this function
        if (this.gameData.namespaces === undefined) {
if (this.gameData.namespaces === undefined) {
            const nsData = [];
const nsData = [];
            game.registeredNamespaces.forEach((ns) => {
game.registeredNamespaces.forEach((ns) => {
                if (ns.isModded) {
if (ns.isModded) {
                    throw new Error(`Modded namespace '${ ns.displayName }' found, all mods must be disabled before game data can be generated`);
throw new Error(`Modded namespace '${ ns.displayName }' found, all mods must be disabled before game data can be generated`);
                }
}
                else {
else {
                    nsData.push(ns);
nsData.push(ns);
                }
}
            });
});
            this.gameData.namespaces = nsData;
this.gameData.namespaces = nsData;
        }
}
        if (this.gameData.combatTriangles === undefined) {
if (this.gameData.combatTriangles === undefined) {
            const ctData = [];
const ctData = [];
            Object.keys(COMBAT_TRIANGLE_IDS).forEach((id) => {
Object.keys(COMBAT_TRIANGLE_IDS).forEach((id) => {
                const newObj = structuredClone(combatTriangle[COMBAT_TRIANGLE_IDS[id]]);
const newObj = structuredClone(combatTriangle[COMBAT_TRIANGLE_IDS[id]]);
                newObj.id = id;
newObj.id = id;
                ctData.push(newObj);
ctData.push(newObj);
            });
});
            this.gameData.combatTriangles = ctData;
this.gameData.combatTriangles = ctData;
        }
}
        if (this.gameData.masteryCheckpoints === undefined) {
if (this.gameData.masteryCheckpoints === undefined) {
            this.gameData.masteryCheckpoints = masteryCheckpoints;
this.gameData.masteryCheckpoints = masteryCheckpoints;
        }
}
        if (this.gameData.combatAreaDifficulties === undefined) {
if (this.gameData.combatAreaDifficulties === undefined) {
            this.gameData.combatAreaDifficulties = CombatAreaMenu.difficulty.map((i) => i.name);
this.gameData.combatAreaDifficulties = CombatAreaMenu.difficulty.map((i) => i.name);
        }
}
        if (this.gameData.equipmentSlots === undefined) {
if (this.gameData.equipmentSlots === undefined) {
            this.gameData.equipmentSlots = EquipmentSlots;
//TODO: Amend to follow { id: ..., name: ... } structure. Obtain name from getLangString('EQUIP_SLOT', numID)
        }
this.gameData.equipmentSlots = EquipmentSlots;
        if (this.gameData.attackTypes === undefined) {
}
            this.gameData.attackTypes = AttackTypeID;
if (this.gameData.attackTypes === undefined) {
        }  
this.gameData.attackTypes = AttackTypeID;
        if (this.gameData.slayerTiers === undefined) {
}  
            const newData = structuredClone(SlayerTask.data)
if (this.gameData.slayerTiers === undefined) {
            newData.forEach((tier) => delete tier.engDisplay);
const newData = structuredClone(SlayerTask.data)
            this.gameData.slayerTiers = newData;
newData.forEach((tier) => delete tier.engDisplay);
        }
this.gameData.slayerTiers = newData;
    }
}
}
combineOrderedData(existingData, newData) {
combineOrderedData(existingData, newData) {
// Elements are inserted at a particular index, controlled by rules
// Elements are inserted at a particular index, controlled by rules
Line 590: Line 517:
return resultData;
return resultData;
}
}
    getNamespacedID(namespace, ID) {
// Determines if properties of entities are to be removed, as they are unused in the wiki
        if (ID.indexOf(':') > 0) {
// and would otherwise bloat the data.
            return ID;
// Returns true if the property is to be removed, false if it is to be retained
        }
isPropertyFiltered(entityType, entity, propertyName) {
        else {
switch(propertyName) {
            return namespace + ':' + ID;
case 'media':
        }
case 'altMedia':
    }
case 'markMedia':
    getLocalID(ID) {
case 'icon':
        if (ID.indexOf(':') > 0) {
case 'barStyle': // See: melvorD:Compost
            return ID.split(':').pop();
case 'buttonStyle':
        }
case 'descriptionGenerator':
        else {
case 'containerID':
            return ID;
case 'headerBgClass':
        }
case 'textClass':
    }
case 'btnClass':
return true;
case 'golbinRaidExclusive':
case 'ignoreCompletion':
case 'obtainFromItemLog':
// Property is boolean & isn't of interest when false
return !entity[propertyName];
case 'validSlots':
case 'occupiesSlots':
case 'equipRequirements':
case 'equipmentStats':
// Property is an array & isn't of interest when zero elements in length
return entity[propertyName].length === 0;
case 'tier':
if (entityType === 'items') {
return entity.tier === 'none';
}
else {
return false;
}
default:
// Always retain property
return false;
}
}
// Specifies rules for transforming values of entity properties.
// Returns undefined if the property has no transformation
transformProperty(entityType, entity, propertyName, namespace) {
switch(propertyName) {
case 'langHint':
case 'langCustomDescription':
return getLangString(entity.category, this.getLocalID(entity.id));
case 'equipmentStats':
const newStats = {};
entity.forEach((stat) => {
if (newStats[stat.key] === undefined) {
newStats[stat.key] = stat.value;
}
else {
newStats[stat.key] += stat.value;
}
});
return newStats;
case 'altSpells':
if (entityType !== 'skillData') {
return undefined;
}
else {
const newData = structuredClone(entity);
newData.forEach((i) => {
i.spellBook = 'altMagic';
});
return newData;
}
default:
return undefined;
}
}
langApply(parentNode, nodeKey, isSkill) {
const nodeName = (isSkill ? parentNode[nodeKey].skillID : nodeKey);
const altMagicDescIDKey = function(data) {
// Accepts an Alt. Magic spell object, returns the ID format for that spell
// Using a function for this as some spells (e.g. Superheat) have bespoke logic
if (data.specialCost !== undefined && data.specialCost.type !== undefined) {
if (data.id.includes('HolyInvocation')) {
return 'HOLY_INVOCATION';
}
switch(data.specialCost.type) {
case 'BarIngredientsWithCoal':
return 'SUPERHEAT';
case 'BarIngredientsWithoutCoal':
return 'SUPERHEAT_NO_COAL';
case 'AnyItem':
if (data.produces !== undefined && data.produces === 'GP') {
return 'ITEM_ALCHEMY';
}
break;
}
}
return 'ALTMAGIC_DESC_{ID}';
};
const shopChainPropKey = function(data, dataKey, propName) {
// Accepts an upgrade chain data object & key of the property being localized
const propToLang = {
chainName: 'chainNameLang',
defaultDescription: 'descriptionLang',
defaultName: 'defaultNameLang'
};
const langPropName = propToLang[dataKey];
if (langPropName !== undefined) {
const langProp = data[langPropName];
if (langProp !== undefined) {
return langProp[propName];
}
}
}
const itemDesc = function(data) {
// Items have varying logic based on the type of item, and the lang data contains
// some incorrect stuff for items whose descriptions are generated entirely
// from modifiers, so just get the description from in-game objects instead.
let desc;
const item = game.items.getObjectByID(data.id);
if (item !== undefined) {
desc = item.description;
if (desc === getLangString('BANK_STRING', '38')) {
// Generic "no description" string
return undefined;
}
// Temporary fix for issue with language data keys for FrostSpark 1H Sword
else if (desc.includes('UNDEFINED TRANSLATION') && data.id === 'melvorTotH:FrostSpark_1H_Sword') {
return getLangString('ITEM_DESCRIPTION', 'Frostspark_1H_Sword')
}
else {
return desc;
}
}
}
const passiveDesc = function(data) {
const passive = game.combatPassives.getObjectByID(data.id);
if (passive !== undefined) {
return passive.description;
}
}
const spAttDesc = function(data) {
const spAtt = game.specialAttacks.getObjectByID(data.id);
if (spAtt !== undefined) {
return spAtt.description;
}
}
const langKeys = {
ancientSpells: {
name: { key: 'MAGIC', idFormat: 'ANCIENT_NAME_{ID}' }
},
archaicSpells: {
name: { key: 'MAGIC', idFormat: 'ARCHAIC_NAME_{ID}' }
},
attackStyles: {
name: { key: 'COMBAT_MISC', idFormat: 'ATTACK_STYLE_NAME_{ID}' }
},
attacks: {
name: { key: 'SPECIAL_ATTACK_NAME' },
description: { stringSpecial: 'spAttDesc' }
},
auroraSpells: {
name: { key: 'MAGIC', idFormat: 'AURORA_NAME_{ID}' }
},
combatAreas: {
name: { key: 'COMBAT_AREA', idFormat: 'NAME_{ID}'}
},
combatPassives: {
name: { key: 'PASSIVES', idFormat: 'NAME_{ID}' },
customDescription: { stringSpecial: 'passiveDesc' }
//customDescription: { key: 'PASSIVES', idFormat: 'DESC_{ID}' }
},
curseSpells: {
name: { key: 'MAGIC', idFormat: 'CURSE_NAME_{ID}' }
},
dungeons: {
name: { key: 'DUNGEON', idFormat: 'NAME_{ID}' }
},
gamemodes: {
name: { key: 'GAMEMODES', idFormat: 'GAMEMODE_NAME_{ID}' },
description: { key: 'GAMEMODES', idFormat: 'GAMEMODE_DESC_{ID}' },
// Gamemodes have an array of rules
rules: { key: 'GAMEMODES', idFormat: 'GAMEMODE_RULES_{ID}_{NUM}' }
},
items: {
name: { key: 'ITEM_NAME' },
customDescription: { stringSpecial: 'itemDesc', onlyIfExists: true }
},
lore: {
title: { key: 'LORE', idFormat: 'TITLE_{ID}' }
},
monsters: {
name: { key: 'MONSTER_NAME' },
description: { key: 'MONSTER_DESCRIPTION' }
},
pets: {
name: { key: 'PET_NAME' }
},
prayers: {
name: { key: 'PRAYER', idFormat: 'PRAYER_NAME_{ID}' }
},
shopCategories: {
name: { key: 'SHOP_CAT' }
},
shopPurchases: {
customName: { key: 'SHOP_NAME', onlyIfExists: true },
customDescription: { key: 'SHOP_DESCRIPTION', onlyIfExists: true }
},
shopUpgradeChains: {
chainName: { keySpecial: 'shopChainKey', idSpecial: 'shopChainID' },
defaultDescription: { keySpecial: 'shopChainKey', idSpecial: 'shopChainID' },
defaultName: { keySpecial: 'shopChainKey', idSpecial: 'shopChainID' }
 
},
slayerAreas: {
name: { key: 'SLAYER_AREA', idFormat: 'NAME_{ID}' },
areaEffectDescription: { key: 'SLAYER_AREA', idFormat: 'EFFECT_{ID}' }
},
standardSpells: {
name: { key: 'MAGIC', idFormat: 'SPELL_NAME_{ID}' }
},
skillData: {
// Each skill is nested within this, so follow much the same structure
// Keys here are each skill's local ID
_common: {
// Special entry, contains lang definitions which are the same
// for all skills
_root: {
name: { key: 'SKILL_NAME', idFormat: '{SKILLID}' }
},
categories: {
name: { key: 'SKILL_CATEGORY', idFormat: '{SKILLID}_{ID}' }
},
customMilestones: {
name: { key: 'MILESTONES', idKey: 'milestoneID' }
},
masteryLevelUnlocks: {
description: { key: 'MASTERY_BONUS', idKey: 'descriptionID', idFormat: '{SKILLID}_{ID}' }
}
},
Agility: {
elitePillars: {
name: { key: 'AGILITY', idFormat: 'PILLAR_NAME_{ID}' }
},
obstacles: {
name: { key: 'AGILITY', idFormat: 'OBSTACLE_NAME_{ID}' }
},
pillars: {
name: { key: 'AGILITY', idFormat: 'PILLAR_NAME_{ID}' }
}
},
Astrology: {
recipes: {
name: { key: 'ASTROLOGY', idFormat: 'NAME_{ID}' }
}
},
Farming: {
categories: {
description: { key: 'SKILL_CATEGORY', idFormat: '{SKILLID}_{ID}_description' },
seedNotice: { key: 'SKILL_CATEGORY', idFormat: '{SKILLID}_{ID}_seedNotice' },
singularName: { key: 'SKILL_CATEGORY', idFormat: '{SKILLID}_{ID}_singular' }
}
},
Fishing: {
areas: {
name: { key: 'FISHING', idFormat: 'AREA_NAME_{ID}' }
}
},
Herblore: {
recipes: {
name: { key: 'POTION_NAME' }
},
Magic: {
altSpells: {
name: { key: 'MAGIC', idFormat: 'ALTMAGIC_NAME_{ID}' },
description: { key: 'MAGIC', idSpecial: 'altMagicDesc' }
}
},
Mining: {
rockData: {
name: { key: 'ORE_NAME' }
}
},
Summoning: {
synergies: {
customDescription: { key: 'SUMMONING_SYNERGY', idKey: 'summonIDs', idFormat: 'DESC_{ID0}_{ID1}', onlyIfExists: true }
}
},
Thieving: {
areas: {
name: { key: 'THIEVING', idFormat: 'AREA_NAME_{ID}' }
},
npcs: {
name: { key: 'THIEVING', idFormat: 'NPC_NAME_{ID}' }
}
},
Township: {
biomes: {
// Can't locate biome description localization, don't think this is exposed in game UI
name: { key: 'TOWNSHIP', idFormat: 'BIOME_{ID}' }
},
buildings: {
// Building description has no localization, as it is unused
name: { key: 'TOWNSHIP', idFormat: 'BUILDING_{ID}' }
},
jobs: {
name: { key: 'TOWNSHIP', idFormat: 'JOB_{ID}' }
},
resources: {
name: { key: 'TOWNSHIP', idFormat: 'RESOURCE_{ID}' }
},
tasks: {
// name is not exposed in game UI, and has no localization
// category is localized in transformDataNode
description: { key: 'TOWNSHIP_TASKS', idFormat: '{ID}_description' }
},
worships: {
name: { key: 'MONSTER_NAME' },
statueName: { key: 'TOWNSHIP', idFormat: 'Statue_of_{ID}' }
}
},
Woodcutting: {
trees: {
name: { key: 'TREE_NAME' }
}
}
}
};
 
// Determine which language key data applies
var langKeyData;
if (isSkill) {
// Combine common & skill specific keys
const skillKey = this.getLocalID(parentNode[nodeKey].skillID);
const langCommon = langKeys.skillData._common;
let langSkill = structuredClone(langKeys.skillData[skillKey]);
 
if (langCommon !== undefined) {
if (langSkill === undefined) {
langSkill = {};
}
Object.keys(langCommon).forEach((k) => {
if (langSkill[k] === undefined) {
langSkill[k] = {};
}
Object.keys(langCommon[k]).forEach((prop) => {
langSkill[k][prop] = langCommon[k][prop];
});
});
}
langKeyData = langSkill;
}
else if (langKeys[nodeKey] !== undefined) {
langKeyData = { _root: langKeys[nodeKey] };
}
else {
console.warn('No lang key data found for ' + nodeKey);
}
 
if (langKeyData !== undefined) {
var dataToTranslate = parentNode[nodeKey];
if (isSkill) {
dataToTranslate = dataToTranslate.data;
}
if (!Array.isArray(dataToTranslate)) {
dataToTranslate = [ dataToTranslate ];
}
dataToTranslate.forEach((tData) => {
Object.keys(langKeyData).forEach((langKey) => {
const targetData = ((langKey === '_root') ? tData : tData[langKey]);
if (targetData !== undefined) {
const targetArr = (Array.isArray(targetData) ? targetData : [ targetData ]);
targetArr.forEach((target) => {
Object.keys(langKeyData[langKey]).forEach((langPropID) => {
const langProp = langKeyData[langKey][langPropID];
if (!langProp.onlyIfExists || target[langPropID] !== undefined) {
const langIDKey = langProp.idKey ?? 'id';
var langIDValue;
if (Array.isArray(target[langIDKey])) {
// The ID key can sometimes be an array of IDs (e.g. Summoning synergies)
langIDValue = target[langIDKey].map((id) => this.getLocalID((id ?? '').toString()));
}
else {
langIDValue = this.getLocalID((target[langIDKey] ?? '').toString());
}
let langIdent = langProp.idFormat;
if (langProp.idSpecial !== undefined) {
// Use a special method to determine the ID format
switch(langProp.idSpecial) {
case 'altMagicDesc':
langIdent = altMagicDescIDKey(target);
break;
case 'shopChainID':
langIdent = this.getLocalID(shopChainPropKey(target, langPropID, 'id'));
break;
}
}
if (langIdent === undefined) {
langIdent = langIDValue;
}
else {
// langIdent is in a specific format
const langTemplate = {}
if (isSkill) {
langTemplate.SKILLID = this.getLocalID(parentNode[nodeKey].skillID);
}
if (Array.isArray(langIDValue)) {
langIDValue.forEach((val, idx) => {
langTemplate['ID' + idx] = this.getLocalID(val);
});
}
else {
langTemplate.ID = langIDValue;
}
Object.keys(langTemplate).forEach((k) => {
langIdent = langIdent.replaceAll('{' + k + '}', langTemplate[k]);
});
}
 
let langCategoryKey = langProp.key;
if (langProp.keySpecial !== undefined) {
// Use a special method to determine the category key
switch(langProp.keySpecial) {
case 'shopChainKey':
langCategoryKey = shopChainPropKey(target, langPropID, 'category');
break;
}
}
 
if (Array.isArray(target[langPropID])) {
target[langPropID].forEach((targetElem, num) => {
const langIdentFinal = langIdent.replaceAll('{NUM}', num.toString());
const langString = this.getLangString(langCategoryKey, langIdentFinal);
target[langPropID][num] = langString;
if (this.debugMode) {
if (langString !== undefined) {
console.debug('Set value of property ' + langPropID + '[' + num.toString() + '] for ' + langIdentFinal + ' in node ' + nodeName + ' to: ' + langString);
}
else {
console.debug('No translation: property ' + langPropID + ' for ' + langIdentFinal + ' in node ' + nodeName);
}
}
});
}
else {
let langString;
if (langProp.stringSpecial !== undefined) {
// Use a custom function to determine the string
switch(langProp.stringSpecial) {
case 'itemDesc':
langString = itemDesc(target);
break;
case 'passiveDesc':
langString = passiveDesc(target);
break;
case 'spAttDesc':
langString = spAttDesc(target);
break;
}
}
else {
langString = this.getLangString(langCategoryKey, langIdent);
}
target[langPropID] = langString;
if (this.debugMode) {
if (langString !== undefined) {
console.debug('Set value of property ' + langPropID + ' for ' + langIdent + ' in node ' + nodeName + ' to: ' + langString);
}
else {
console.debug('No translation: property ' + langPropID + ' for ' + langIdent + ' in node ' + nodeName);
}
}
}
}
});
});
}
});
});
}
}
getLangString(key, identifier) {
const langCat = loadedLangJson[key];
if (langCat !== undefined) {
return langCat[identifier];
}
}
getNamespacedID(namespace, ID) {
if (ID.indexOf(':') > 0) {
return ID;
}
else {
return namespace + ':' + ID;
}
}
getLocalID(ID) {
if (ID.indexOf(':') > 0) {
return ID.split(':').pop();
}
else {
return ID;
}
}
}
}


let wd = new Wiki;
let wd = new Wiki;
wd.printWikiData();</pre>}}
wd.printWikiData();</pre>}}

Revision as of 00:42, 20 November 2022

To generate game data, do the following:

  1. Navigate to https://melvoridle.com within your preferred web browser
  2. Select any character, the character that is chosen has no impact but you may consider creating a new one as a precaution - the below code is designed to execute without affecting the character, although this is not guaranteed
  3. Ensure mods are disabled such that the generated data excludes any modded content. If disabling mods, the game should be reloaded first before trying to generate game data
  4. Open the browser console/developer mode (usually by hitting the F12 key for most browsers)
  5. Within the browser console, enter the following code then hit enter. If successful, the game data should appear within the console
  6. Copy the game data & update Module:GameData/data accordingly
Code
// TODO:
// Handle modifications portion of data packages
class Wiki {
	constructor() {
		this.debugMode = false;
		this.prettyPrint = false;
		this.namespaces = {
			melvorD: { displayName: "Demo", url: "https://" + location.hostname + "/assets/data/melvorDemo.json" },
			melvorF: { displayName: "Full Version", url: "https://" + location.hostname + "/assets/data/melvorFull.json" },
			melvorTotH: { displayName: "Throne of the Herald", url: "https://" + location.hostname + "/assets/data/melvorTotH.json" }
		};
		// Check all required namespaces are registered, as there are still some bits of data extracted from in-game rather than the data packages
		Object.keys(this.namespaces).forEach((nsID) => {
			const nsTest = game.registeredNamespaces.getNamespace(nsID);
			if (nsTest === undefined) {
				throw new Error(`Namespace ${ nsID } (${ this.namespaces[nsID].displayName }) is not registered - Ensure you are signed in and have the expansion.`);
			}
		});

		this.packData = {};
		this.gameData = {};
		this.skillDataInit = {};
	};
	async getWikiData() {
		if (!isLoaded) {
			throw new Error('Game must be loaded into a character first');
		}
		for (const nsIdx in Object.keys(this.namespaces)) {
			const ns = Object.keys(this.namespaces)[nsIdx];
			const dataURL = this.namespaces[ns].url;
			console.log(`URL: ${ dataURL }`);
			const dataPackage = await this.getDataPackage(dataURL);
			if (dataPackage.namespace === undefined) {
				throw new Error(`Data package has no namespace: ${ dataURL }`);
			}
			else if (dataPackage.data === undefined) {
				throw new Error(`Data package has no data: ${ dataURL }`);
			}
			console.log(`Obtained data for namespace ${ dataPackage.namespace }, ${ JSON.stringify(dataPackage.data).length.toLocaleString() } bytes`);
			this.processDataPackage(dataPackage);
			console.log(`After transformation: ${ JSON.stringify(dataPackage.data).length.toLocaleString() } bytes`);
		}
		// All data packages should now be within this.gameData
	}
	getGameVersion() {
		const fileDOM = document.querySelector('#sidebar ul.nav-main');
		let fileVer = "Unknown";
		if (fileDOM !== null && fileDOM.dataset !== undefined) {
			fileVer = fileDOM.dataset.fileVersion;
		}
		return gameVersion + ' (' + fileVer + ')';
	}
	async printWikiData() {
		if (!isLoaded) {
			throw new Error('Game must be loaded into a character first');
		}
		if (Object.keys(this.packData).length < Object.keys(this.namespaces).length) {
			// Need to retrieve game data first
			const result = await this.getWikiData();
		}
		let dataObjText;
		if (this.prettyPrint) {
			dataObjText = JSON.stringify(this.gameData, undefined, '\t');
		}
		else {
			dataObjText = JSON.stringify(this.gameData);
		}
		dataObjText = dataObjText.replace(/\'/g, "\\\'");
		dataObjText = dataObjText.replace(/\\\"/g, "\\\\\"");

		let dataText = '-- Version: ' + this.getGameVersion();
		dataText += "\r\n\r\nlocal gameData = mw.text.jsonDecode('";
		dataText += dataObjText;
		dataText += "')\r\n\r\nreturn gameData";
		console.log(dataText);
	}
	async getDataPackage(url) {
		// Based largely on Game.fetchAndRegisterDataPackage()
		const headers = new Headers();
		headers.append('Content-Type', 'application/json');
		return await fetch(url, {
			method: 'GET',
			headers
		}).then(function(response) {
			if (!response.ok) {
				throw new Error(`Couldn't fetch data package from URL: ${ url }`);
			}
			return response.json();
		});
	}
	processDataPackage(dataPackage) {
		// Transforms the raw data from data packages in various ways, then
		// consolidates into this.packData & this.gameData
		const ns = dataPackage.namespace;
		const packData = dataPackage.data;

		this.transformDataPackage(dataPackage);
		this.packData[dataPackage.namespace] = dataPackage;
		this.registerDataPackage(dataPackage.namespace);
	}
	transformDataPackage(dataPackage) {
		// Takes a raw data package and performs various manipulations
		const ns = dataPackage.namespace;
		const packData = dataPackage.data;

		Object.keys(packData).forEach((categoryName) => {
			switch(categoryName) {
				case 'bankSortOrder':
				case 'pages':
				case 'steamAchievements':
				case 'tutorialStageOrder':
				case 'tutorialStages':
					// This data serves no purpose for the wiki and only serves to bloat
					// the data, so simply delete it
					delete packData[categoryName];
					break;
				default:
					this.transformDataNode(ns, categoryName, packData, categoryName);
					break;
			}
		});
	}
	transformDataNode(ns, categoryName, parentNode, nodeKey) {
		let dataNode = parentNode[nodeKey];
		const transformedValue = this.transformProperty(categoryName, dataNode, nodeKey, ns);
		if (transformedValue !== undefined) {
			// A transformed value exists for this node
			parentNode[nodeKey] = transformedValue;
			dataNode = parentNode[nodeKey];
		}
		if (Array.isArray(dataNode)) {
			// Recursive call to ensure all data is transformed, regardless of its depth
			dataNode.forEach((entity, idx) => this.transformDataNode(ns, categoryName, dataNode, idx));
		}
		else if (typeof dataNode === 'object' && dataNode !== null) {
			// Iterate properties of object, checking if each should be deleted or transformed
			Object.keys(dataNode).forEach((key) => {
				// Check if property is to be deleted or not
				if (this.isPropertyFiltered(categoryName, dataNode, key)) {
					delete dataNode[key];
				}
				else if (typeof dataNode[key] === "object" && dataNode[key] !== null) {
					// If an object (either an array or key/value store) is within the current
					// object then we must traverse this too
					this.transformDataNode(ns, categoryName, dataNode, key);
				}
				else {
					// Transform property, if a transformation is defined below
					switch(key) {
						case 'id':
							// Add namespace to ID if it isn't already
							dataNode[key] = this.getNamespacedID(ns, dataNode[key]);
							break;
					}
				}
			});
		}
		// Apply localization, except for if this is skill data. That is handled separately below
		if (categoryName !== 'skillData' && categoryName == nodeKey) {
			this.langApply(parentNode, nodeKey, false);
		}

		// Special case for skillData so that certain values initialized when the various Skill
		// classes are initialized may be added here also
		if ((categoryName === 'skillData') && dataNode.skillID !== undefined && dataNode.data !== undefined) {
			// We are currently at the topmost level of a skill object
			if (!this.skillDataInit[dataNode.skillID]) {
				const gameSkill = game.skills.getObjectByID(dataNode.skillID);
				if (gameSkill !== undefined) {
					if (gameSkill.milestones !== undefined && dataNode.data.milestoneCount === undefined) {
						dataNode.data.milestoneCount = gameSkill.milestones.length;
					}
					// For every skill with mastery, add mastery checkpoint descriptions
					if (gameSkill instanceof SkillWithMastery && dataNode.data.masteryTokenID !== undefined && dataNode.data.masteryCheckpoints === undefined) {
						const localID = this.getLocalID(dataNode.skillID);
						dataNode.data.baseMasteryPoolCap = gameSkill.baseMasteryPoolCap;
						dataNode.data.masteryCheckpoints = [];
						masteryCheckpoints.forEach((pct, idx) => {
							dataNode.data.masteryCheckpoints[idx] = getLangString('MASTERY_CHECKPOINT', `${ localID }_${ idx }`);
						});
					}

					// Import other attributes varying by skill
					let importKeys = [];
					switch(dataNode.skillID) {
						case 'melvorD:Firemaking':
							importKeys = [
								'baseAshChance',
								'baseStardustChance',
								'baseCharcoalChance'
							];
							break;
						case 'melvorD:Mining':
							importKeys = [
								'baseInterval',
								'baseRockHP',
								'passiveRegenInterval'
							];
							dataNode.data.baseGemChance = 1;
							dataNode.data.rockTypes = loadedLangJson.MINING_TYPE;
							break;
						case 'melvorD:Smithing':
						case 'melvorD:Fletching':
						case 'melvorD:Crafting':
						case 'melvorD:Runecrafting':
						case 'melvorD:Herblore':
							importKeys = [
								'baseInterval'
							];
							break;
						case 'melvorD:Thieving':
							importKeys = [
								'baseInterval',
								'baseStunInterval',
								'itemChance',
								'baseAreaUniqueChance'
							];
							break;
						case 'melvorD:Agility':
							importKeys = [
								'obstacleUnlockLevels'
							];
							break;
						case 'melvorD:Summoning':
							importKeys = [
								'baseInterval'
							];
							const sumKeys = [
								'recipeGPCost',
								'markLevels'  
							];
							sumKeys.forEach((k) => dataNode.data[k] = Summoning[k]);
							break;
						case 'melvorD:Astrology':
							// Astrology has a number of values stored outside of gameSkill
							const astKeys = [
								'standardModifierLevels',
								'uniqueModifierLevels',
								'standardModifierCosts',
								'uniqueModifierCosts',
								'baseStardustChance',
								'baseGoldenStardustChance',
								'baseInterval'
							];
							astKeys.forEach((k) => dataNode.data[k] = Astrology[k]);
							break;
						case 'melvorD:Township':
							// Remap a number of keys from their in-game names
							const townKeys = [
								{'from': 'TICK_LENGTH', 'to': 'tickLength'},
								{'from': 'MAX_TOWN_SIZE', 'to': 'maxTownSize'},
								{'from': 'SECTION_SIZE', 'to': 'sectionSize'},
								{'from': 'INITIAL_CITIZEN_COUNT', 'to': 'initialCitizenCount'},
								{'from': 'MIN_WORKER_AGE', 'to': 'minWorkerAge'},
								{'from': 'MAX_WORKER_AGE', 'to': 'maxWorkerAge'},
								{'from': 'AGE_OF_DEATH', 'to': 'ageOfDeath'},
								{'from': 'MIN_MIGRATION_AGE', 'to': 'minMigrationAge'},
								{'from': 'MAX_MIGRATION_AGE', 'to': 'maxMigrationAge'},
								{'from': 'BASE_TAX_RATE', 'to': 'baseTaxRate'},
								{'from': 'EDUCATION_PER_CITIZEN', 'to': 'educationPerCitizen'},
								{'from': 'HAPPINESS_PER_CITIZEN', 'to': 'happinessPerCitizen'},
								{'from': 'CITIZEN_FOOD_USAGE', 'to': 'citizenFoodUsage'},
								{'from': 'POPULATION_REQUIRED_FOR_BIRTH', 'to': 'populationRequiredForBirth'},
								{'from': 'BASE_STORAGE', 'to': 'baseStorage'},
								{'from': 'WORSHIP_CHECKPOINTS', 'to': 'worshipCheckpoints'},
								{'from': 'MAX_WORSHIP', 'to': 'maxWorship'},
								{'from': 'populationForTier', 'to': 'populationForTier'},
								{'from': 'MAX_TRADER_STOCK_INCREASE', 'to': 'maxTraderStockIncrease'},
							];
							townKeys.forEach((k) => dataNode.data[k.to] = gameSkill[k.from]);
							// Add task categories & localization of name
							const taskCategories = Array.from(new Set(gameSkill.tasks.tasks.allObjects.map((t) => t.category)));
							dataNode.data.taskCategories = taskCategories.map((i) => ({ id: i, name: gameSkill.tasks.getTownshipTaskCategoryName(i)}));
							break;
					}
					if (importKeys.length > 0) {
						importKeys.forEach((k) => dataNode.data[k] = gameSkill[k]);
					}
				}
				this.skillDataInit[dataNode.skillID] = true;
			}
			// Appy localization (skills)
			this.langApply(parentNode, nodeKey, true);
		}
	}
	registerDataPackage(namespace) {
		// Consolidates the data package identified by namespace with existing data within
		// this.gameData
		const packData = this.packData[namespace].data;
		if (packData === undefined) {
			throw new Error(`Couldn't find data for package ${ namespace }`);
		}
		// Add data within the game but outside of data packs
		this.registerNonPackData();
		// Consolidate data
		Object.keys(packData).forEach((categoryName) => {
			let categoryData = packData[categoryName];
			// Some data is adjusted before combining - do this here
			if (['combatAreas', 'dungeons', 'slayerAreas'].includes(categoryName)) {
				// Add area type to each area object
				const areaTypes = {
					'combatAreas': 'combatArea',
					'dungeons': 'dungeon',
					'slayerAreas': 'slayerArea'
				}
				const areaType = areaTypes[categoryName];
				const newData = structuredClone(categoryData);
				newData.forEach((x) => x.type = areaType);
				categoryData = newData;
			}
			else if (['ancientSpells', 'archaicSpells', 'auroraSpells', 'curseSpells', 'standardSpells'].includes(categoryName)) {
				// For spell books, add the spell type to each spell object.
				// Alt Magic spells are handled elsewhere, as they are within a skill object
				const spellType = categoryName.replace('Spells', '');
				const newData = structuredClone(categoryData);
				newData.forEach((x) => x.spellBook = spellType);
				categoryData = newData;
			}
			else if (categoryName === 'golbinRaid') {

			}
			// Data must be pushed into the consoldiated data, rules for vary
			// depending on the category in question
			switch(categoryName) {
				case 'ancientSpells':
				case 'archaicSpells':
				case 'attackStyles':
				case 'attacks':
				case 'auroraSpells':
				case 'combatAreas':
				case 'combatEvents':
				case 'combatPassives':
				case 'curseSpells':
				case 'dungeons':
				case 'gamemodes':
				case 'itemEffects':
				case 'itemSynergies':
				case 'itemUpgrades':
				case 'itmMonsters':
				case 'items':
				case 'lore':
				case 'monsters':
				case 'pages':
				case 'pets':
				case 'prayers':
				case 'randomGems':
				case 'randomSuperiorGems':
				case 'shopCategories':
				case 'shopPurchases':
				case 'shopUpgradeChains':
				case 'slayerAreas':
				case 'stackingEffects':
				case 'standardSpells':
				case 'steamAchievements':
				case 'tutorialStages':
				case 'spiderLairMonsters':
					// Plain old push to the end of the array
					if (this.gameData[categoryName] === undefined) {
						// Category doesn't exist yet in consolidated data, so create it
						this.gameData[categoryName] = categoryData;
					}
					else {
						this.gameData[categoryName].push(...categoryData);
					}
					break;
				case 'combatAreaDisplayOrder':
				case 'dungeonDisplayOrder':
				case 'shopCategoryOrder':
				case 'shopDisplayOrder':
				case 'slayerAreaDisplayOrder':
				case 'tutorialStageOrder':
					// Elements are inserted at a particular index, controlled by rules
					// specified within the data package
					this.gameData[categoryName] = this.combineOrderedData(this.gameData[categoryName], categoryData);
					break;
				case 'golbinRaid':
					// Properties contain unordered arrays that need to be combined
					if (this.gameData[categoryName] === undefined) {
						this.gameData[categoryName] = categoryData;
						this.gameData.golbinRaid.possibleModifiers = RaidManager.possibleModifiers;
					}
					else {
						Object.keys(categoryData).forEach((dataKey) => {
							if ((this.gameData[categoryName][dataKey] === undefined) || !Array.isArray(this.gameData[categoryName][dataKey])) {
								// Property is undefined or isn't an array
								this.gameData[categoryName][dataKey] = categoryData[dataKey];
							} 
							else {
								// Property is an array
								this.gameData[categoryName][dataKey].push(...categoryData[dataKey]);
							}
						});
					}
					break;
				case 'skillData':
					// Contains nested objects
					if (this.gameData[categoryName] === undefined) {
						this.gameData[categoryName] = [];
					}
					// Find the appropriate skill object and combine properties with that
					categoryData.forEach((skillData) => {
						var skillIdx = this.gameData[categoryName].findIndex((skill) => skill.skillID === skillData.skillID);
						if (skillIdx === -1) {
							// Initialize skill
							const initData = structuredClone(skillData);
							initData.data = {};
							this.gameData[categoryName].push(initData);
							skillIdx = this.gameData[categoryName].findIndex((skill) => skill.skillID === skillData.skillID);
						}
						const skillObj = this.gameData[categoryName][skillIdx].data;
						Object.keys(skillData.data).forEach((dataKey) => {
							if (Array.isArray(skillData.data[dataKey]) && skillData.data[dataKey].length > 0 && skillData.data[dataKey][0].insertAt !== undefined) {
								//Data is ordered, special handling applies
								skillObj[dataKey] = this.combineOrderedData(skillObj[dataKey], skillData.data[dataKey]);
							}
							else if ((skillObj[dataKey] === undefined) || !Array.isArray(skillObj[dataKey])) {
								// Property is undefined or isn't an array
									skillObj[dataKey] = skillData.data[dataKey];
							}
							else {
								// Property is an array
									skillObj[dataKey].push(...skillData.data[dataKey]);
							}
						});
					});
					break;
				default:
					console.warn(`Skipping unknown category while registering data package: ${ categoryName }`);
					break;
			}
		});
	}
	registerNonPackData() {
		// Some data resides outside of packages. Add any such data to this.gameData within this function
		if (this.gameData.namespaces === undefined) {
			const nsData = [];
			game.registeredNamespaces.forEach((ns) => {
				if (ns.isModded) {
					throw new Error(`Modded namespace '${ ns.displayName }' found, all mods must be disabled before game data can be generated`);
				}
				else {
					nsData.push(ns);
				}
			});
			this.gameData.namespaces = nsData;
		}
		if (this.gameData.combatTriangles === undefined) {
			const ctData = [];
			Object.keys(COMBAT_TRIANGLE_IDS).forEach((id) => {
				const newObj = structuredClone(combatTriangle[COMBAT_TRIANGLE_IDS[id]]);
				newObj.id = id;
				ctData.push(newObj);
			});
			this.gameData.combatTriangles = ctData;
		}
		if (this.gameData.masteryCheckpoints === undefined) {
			this.gameData.masteryCheckpoints = masteryCheckpoints;
		}
		if (this.gameData.combatAreaDifficulties === undefined) {
			this.gameData.combatAreaDifficulties = CombatAreaMenu.difficulty.map((i) => i.name);
		}
		if (this.gameData.equipmentSlots === undefined) {
			//TODO: Amend to follow { id: ..., name: ... } structure. Obtain name from getLangString('EQUIP_SLOT', numID)
			this.gameData.equipmentSlots = EquipmentSlots;
		}
		if (this.gameData.attackTypes === undefined) {
			this.gameData.attackTypes = AttackTypeID;
		} 
		if (this.gameData.slayerTiers === undefined) {
			const newData = structuredClone(SlayerTask.data)
			newData.forEach((tier) => delete tier.engDisplay);
			this.gameData.slayerTiers = newData;
		}
	}
	combineOrderedData(existingData, newData) {
		// Elements are inserted at a particular index, controlled by rules
		// specified within the data package
		var resultData = undefined;
		if (existingData === undefined) {
			resultData = [];
		}
		else {
			resultData = structuredClone(existingData);
		}
		newData.forEach((orderData) => {
			switch(orderData.insertAt) {
				case 'Start':
					resultData.splice(0, 0, ...orderData.ids);
					break;
				case 'End':
					resultData.push(...orderData.ids);
					break;
				case 'Before':
					const beforeIdx = resultData.findIndex((item) => item === orderData.beforeID);
					if (beforeIdx === -1) {
						throw new Error(`Couldn't insert before: Item ${ orderData.beforeID } is not in the array.`);
					}
					resultData.splice(beforeIndex, 0, ...orderData.ids);
					break;
				case 'After':
					const afterIdx = resultData.findIndex((item) => item === orderData.afterID);
					if (afterIdx === -1) {
						throw new Error(`Couldn't insert after: Item ${ orderData.afterID } is not in the array.`);
					}
					resultData.splice(afterIdx + 1, 0, ...orderData.ids);
					break;
			}
		});
		return resultData;
	}
	// Determines if properties of entities are to be removed, as they are unused in the wiki
	// and would otherwise bloat the data.
	// Returns true if the property is to be removed, false if it is to be retained
	isPropertyFiltered(entityType, entity, propertyName) {
		switch(propertyName) {
			case 'media':
			case 'altMedia':
			case 'markMedia':
			case 'icon':
			case 'barStyle': // See: melvorD:Compost
			case 'buttonStyle':
			case 'descriptionGenerator':
			case 'containerID':
			case 'headerBgClass':
			case 'textClass':
			case 'btnClass':
				return true;
			case 'golbinRaidExclusive':
			case 'ignoreCompletion':
			case 'obtainFromItemLog':
				// Property is boolean & isn't of interest when false
				return !entity[propertyName];
			case 'validSlots':
			case 'occupiesSlots':
			case 'equipRequirements':
			case 'equipmentStats':
				// Property is an array & isn't of interest when zero elements in length
				return entity[propertyName].length === 0;
			case 'tier':
				if (entityType === 'items') {
					return entity.tier === 'none';
				}
				else {
					return false;
				}
			default:
				// Always retain property
				return false;
		}
	}
	// Specifies rules for transforming values of entity properties.
	// Returns undefined if the property has no transformation
	transformProperty(entityType, entity, propertyName, namespace) {
		switch(propertyName) {
			case 'langHint':
			case 'langCustomDescription':
				return getLangString(entity.category, this.getLocalID(entity.id));
			case 'equipmentStats':
				const newStats = {};
				entity.forEach((stat) => {
					if (newStats[stat.key] === undefined) {
						newStats[stat.key] = stat.value;
					}
					else {
						newStats[stat.key] += stat.value;
					}
				});
				return newStats;
			case 'altSpells':
				if (entityType !== 'skillData') {
					return undefined;
				}
				else {
					const newData = structuredClone(entity);
					newData.forEach((i) => {
						i.spellBook = 'altMagic';
					});
					return newData;
				}
			default:
				return undefined;
		}
	}
	langApply(parentNode, nodeKey, isSkill) {
		const nodeName = (isSkill ? parentNode[nodeKey].skillID : nodeKey);
		const altMagicDescIDKey = function(data) {
			// Accepts an Alt. Magic spell object, returns the ID format for that spell
			// Using a function for this as some spells (e.g. Superheat) have bespoke logic
			if (data.specialCost !== undefined && data.specialCost.type !== undefined) {
				if (data.id.includes('HolyInvocation')) {
					return 'HOLY_INVOCATION';
				}
				switch(data.specialCost.type) {
					case 'BarIngredientsWithCoal':
						return 'SUPERHEAT';
					case 'BarIngredientsWithoutCoal':
						return 'SUPERHEAT_NO_COAL';
					case 'AnyItem':
						if (data.produces !== undefined && data.produces === 'GP') {
							return 'ITEM_ALCHEMY';
						}
						break;
				}
			}
			return 'ALTMAGIC_DESC_{ID}';
		};
		const shopChainPropKey = function(data, dataKey, propName) {
			// Accepts an upgrade chain data object & key of the property being localized
			const propToLang = {
				chainName: 'chainNameLang',
				defaultDescription: 'descriptionLang',
				defaultName: 'defaultNameLang'
			};
			const langPropName = propToLang[dataKey];
			if (langPropName !== undefined) {
				const langProp = data[langPropName];
				if (langProp !== undefined) {
					return langProp[propName];
				}
			}
		}
		const itemDesc = function(data) {
			// Items have varying logic based on the type of item, and the lang data contains
			// some incorrect stuff for items whose descriptions are generated entirely
			// from modifiers, so just get the description from in-game objects instead.
			let desc;
			const item = game.items.getObjectByID(data.id);
			if (item !== undefined) {
				desc = item.description;
				if (desc === getLangString('BANK_STRING', '38')) {
					// Generic "no description" string
					return undefined;
				}
				// Temporary fix for issue with language data keys for FrostSpark 1H Sword
				else if (desc.includes('UNDEFINED TRANSLATION') && data.id === 'melvorTotH:FrostSpark_1H_Sword') {
					return getLangString('ITEM_DESCRIPTION', 'Frostspark_1H_Sword')
				}
				else {
					return desc;
				}
			}
		}
		const passiveDesc = function(data) {
			const passive = game.combatPassives.getObjectByID(data.id);
			if (passive !== undefined) {
				return passive.description;
			}
		}
		const spAttDesc = function(data) {
			const spAtt = game.specialAttacks.getObjectByID(data.id);
			if (spAtt !== undefined) {
				return spAtt.description;
			}
		}
		const langKeys = {
			ancientSpells: {
				name: { key: 'MAGIC', idFormat: 'ANCIENT_NAME_{ID}' }
			},
			archaicSpells: {
				name: { key: 'MAGIC', idFormat: 'ARCHAIC_NAME_{ID}' }
			},
			attackStyles: {
				name: { key: 'COMBAT_MISC', idFormat: 'ATTACK_STYLE_NAME_{ID}' }
			},
			attacks: {
				name: { key: 'SPECIAL_ATTACK_NAME' },
				description: { stringSpecial: 'spAttDesc' }
			},
			auroraSpells: {
				name: { key: 'MAGIC', idFormat: 'AURORA_NAME_{ID}' }
			},
			combatAreas: {
				name: { key: 'COMBAT_AREA', idFormat: 'NAME_{ID}'}
			},
			combatPassives: {
				name: { key: 'PASSIVES', idFormat: 'NAME_{ID}' },
				customDescription: { stringSpecial: 'passiveDesc' }
				//customDescription: { key: 'PASSIVES', idFormat: 'DESC_{ID}' }
			},
			curseSpells: {
				name: { key: 'MAGIC', idFormat: 'CURSE_NAME_{ID}' }
			},
			dungeons: {
				name: { key: 'DUNGEON', idFormat: 'NAME_{ID}' }
			},
			gamemodes: {
				name: { key: 'GAMEMODES', idFormat: 'GAMEMODE_NAME_{ID}' },
				description: { key: 'GAMEMODES', idFormat: 'GAMEMODE_DESC_{ID}' },
				// Gamemodes have an array of rules
				rules: { key: 'GAMEMODES', idFormat: 'GAMEMODE_RULES_{ID}_{NUM}' }
			},
			items: {
				name: { key: 'ITEM_NAME' },
				customDescription: { stringSpecial: 'itemDesc', onlyIfExists: true }
			},
			lore: {
				title: { key: 'LORE', idFormat: 'TITLE_{ID}' }
			},
			monsters: {
				name: { key: 'MONSTER_NAME' },
				description: { key: 'MONSTER_DESCRIPTION' }
			},
			pets: {
				name: { key: 'PET_NAME' }
			},
			prayers: {
				name: { key: 'PRAYER', idFormat: 'PRAYER_NAME_{ID}' }
			},
			shopCategories: {
				name: { key: 'SHOP_CAT' }
			},
			shopPurchases: {
				customName: { key: 'SHOP_NAME', onlyIfExists: true },
				customDescription: { key: 'SHOP_DESCRIPTION', onlyIfExists: true }
			},
			shopUpgradeChains: {
				chainName: { keySpecial: 'shopChainKey', idSpecial: 'shopChainID' },
				defaultDescription: { keySpecial: 'shopChainKey', idSpecial: 'shopChainID' },
				defaultName: { keySpecial: 'shopChainKey', idSpecial: 'shopChainID' }

			},
			slayerAreas: {
				name: { key: 'SLAYER_AREA', idFormat: 'NAME_{ID}' },
				areaEffectDescription: { key: 'SLAYER_AREA', idFormat: 'EFFECT_{ID}' }
			},
			standardSpells: {
				name: { key: 'MAGIC', idFormat: 'SPELL_NAME_{ID}' }
			},
			skillData: {
				// Each skill is nested within this, so follow much the same structure
				// Keys here are each skill's local ID
				_common: {
					// Special entry, contains lang definitions which are the same
					// for all skills
					_root: {
						name: { key: 'SKILL_NAME', idFormat: '{SKILLID}' }
					},
					categories: {
						name: { key: 'SKILL_CATEGORY', idFormat: '{SKILLID}_{ID}' }
					},
					customMilestones: {
						name: { key: 'MILESTONES', idKey: 'milestoneID' }
					},
					masteryLevelUnlocks: {
						description: { key: 'MASTERY_BONUS', idKey: 'descriptionID', idFormat: '{SKILLID}_{ID}' }
					}
				},
				Agility: {
					elitePillars: {
						name: { key: 'AGILITY', idFormat: 'PILLAR_NAME_{ID}' }
					},
					obstacles: {
						name: { key: 'AGILITY', idFormat: 'OBSTACLE_NAME_{ID}' }
					},
					pillars: {
						name: { key: 'AGILITY', idFormat: 'PILLAR_NAME_{ID}' }
					}
				},
				Astrology: {
					recipes: {
						name: { key: 'ASTROLOGY', idFormat: 'NAME_{ID}' }
					}
				},
				Farming: {
					categories: {
						description: { key: 'SKILL_CATEGORY', idFormat: '{SKILLID}_{ID}_description' },
						seedNotice: { key: 'SKILL_CATEGORY', idFormat: '{SKILLID}_{ID}_seedNotice' },
						singularName: { key: 'SKILL_CATEGORY', idFormat: '{SKILLID}_{ID}_singular' }
					}
				},
				Fishing: {
					areas: {
						name: { key: 'FISHING', idFormat: 'AREA_NAME_{ID}' }
					}
				},
				Herblore: {
					recipes: {
						name: { key: 'POTION_NAME' }
					}  
				},
				Magic: {
					altSpells: {
						name: { key: 'MAGIC', idFormat: 'ALTMAGIC_NAME_{ID}' },
						description: { key: 'MAGIC', idSpecial: 'altMagicDesc' }
					}
				},
				Mining: {
					rockData: {
						name: { key: 'ORE_NAME' }
					}
				},
				Summoning: {
					synergies: {
						customDescription: { key: 'SUMMONING_SYNERGY', idKey: 'summonIDs', idFormat: 'DESC_{ID0}_{ID1}', onlyIfExists: true }
					}
				},
				Thieving: {
					areas: {
						name: { key: 'THIEVING', idFormat: 'AREA_NAME_{ID}' }
					},
					npcs: {
						name: { key: 'THIEVING', idFormat: 'NPC_NAME_{ID}' }
					}
				},
				Township: {
					biomes: {
						// Can't locate biome description localization, don't think this is exposed in game UI
						name: { key: 'TOWNSHIP', idFormat: 'BIOME_{ID}' }
					},
					buildings: {
						// Building description has no localization, as it is unused
						name: { key: 'TOWNSHIP', idFormat: 'BUILDING_{ID}' }
					},
					jobs: {
						name: { key: 'TOWNSHIP', idFormat: 'JOB_{ID}' }
					},
					resources: {
						name: { key: 'TOWNSHIP', idFormat: 'RESOURCE_{ID}' }
					},
					tasks: {
						// name is not exposed in game UI, and has no localization
						// category is localized in transformDataNode
						description: { key: 'TOWNSHIP_TASKS', idFormat: '{ID}_description' }
					},
					worships: {
						name: { key: 'MONSTER_NAME' },
						statueName: { key: 'TOWNSHIP', idFormat: 'Statue_of_{ID}' }
					}
				},
				Woodcutting: {
					trees: {
						name: { key: 'TREE_NAME' }
					}
				}
			}
		};

		// Determine which language key data applies
		var langKeyData;
		if (isSkill) {
			// Combine common & skill specific keys
			const skillKey = this.getLocalID(parentNode[nodeKey].skillID);
			const langCommon = langKeys.skillData._common;
			let langSkill = structuredClone(langKeys.skillData[skillKey]);

			if (langCommon !== undefined) {
				if (langSkill === undefined) {
					langSkill = {};
				}
				Object.keys(langCommon).forEach((k) => {
					if (langSkill[k] === undefined) {
						langSkill[k] = {};
					}
					Object.keys(langCommon[k]).forEach((prop) => {
						langSkill[k][prop] = langCommon[k][prop];
					});
				});
			}
			langKeyData = langSkill;
		}
		else if (langKeys[nodeKey] !== undefined) {
			langKeyData = { _root: langKeys[nodeKey] };
		}
		else {
			console.warn('No lang key data found for ' + nodeKey);
		}

		if (langKeyData !== undefined) {
			var dataToTranslate = parentNode[nodeKey];
			if (isSkill) {
				dataToTranslate = dataToTranslate.data;
			}
			if (!Array.isArray(dataToTranslate)) {
				dataToTranslate = [ dataToTranslate ];
			}
			dataToTranslate.forEach((tData) => {
				Object.keys(langKeyData).forEach((langKey) => {
					const targetData = ((langKey === '_root') ? tData : tData[langKey]);
					if (targetData !== undefined) {
						const targetArr = (Array.isArray(targetData) ? targetData : [ targetData ]);
						targetArr.forEach((target) => {
							Object.keys(langKeyData[langKey]).forEach((langPropID) => {
								const langProp = langKeyData[langKey][langPropID];
								if (!langProp.onlyIfExists || target[langPropID] !== undefined) {
									const langIDKey = langProp.idKey ?? 'id';
									var langIDValue;
									if (Array.isArray(target[langIDKey])) {
										// The ID key can sometimes be an array of IDs (e.g. Summoning synergies)
										langIDValue = target[langIDKey].map((id) => this.getLocalID((id ?? '').toString()));
									}
									else {
										langIDValue = this.getLocalID((target[langIDKey] ?? '').toString());
									}
									let langIdent = langProp.idFormat;
									if (langProp.idSpecial !== undefined) {
										// Use a special method to determine the ID format
										switch(langProp.idSpecial) {
											case 'altMagicDesc':
												langIdent = altMagicDescIDKey(target);
												break;
											case 'shopChainID':
												langIdent = this.getLocalID(shopChainPropKey(target, langPropID, 'id'));
												break;
										}
									}
									if (langIdent === undefined) {
										langIdent = langIDValue;
									}
									else {
										// langIdent is in a specific format
										const langTemplate = {}
										if (isSkill) {
											langTemplate.SKILLID = this.getLocalID(parentNode[nodeKey].skillID);
										}
										if (Array.isArray(langIDValue)) {
											langIDValue.forEach((val, idx) => {
												langTemplate['ID' + idx] = this.getLocalID(val);
											});
										}
										else {
											langTemplate.ID = langIDValue;
										}
										Object.keys(langTemplate).forEach((k) => {
											langIdent = langIdent.replaceAll('{' + k + '}', langTemplate[k]);
										});
									}

									let langCategoryKey = langProp.key;
									if (langProp.keySpecial !== undefined) {
										// Use a special method to determine the category key
										switch(langProp.keySpecial) {
											case 'shopChainKey':
												langCategoryKey = shopChainPropKey(target, langPropID, 'category');
												break;
										}
									}

									if (Array.isArray(target[langPropID])) {
										target[langPropID].forEach((targetElem, num) => {
											const langIdentFinal = langIdent.replaceAll('{NUM}', num.toString());
											const langString = this.getLangString(langCategoryKey, langIdentFinal);
											target[langPropID][num] = langString;
											if (this.debugMode) {
												if (langString !== undefined) {
													console.debug('Set value of property ' + langPropID + '[' + num.toString() + '] for ' + langIdentFinal + ' in node ' + nodeName + ' to: ' + langString);
												}
												else {
													console.debug('No translation: property ' + langPropID + ' for ' + langIdentFinal + ' in node ' + nodeName);
												}
											}
										});
									}
									else {
										let langString;
										if (langProp.stringSpecial !== undefined) {
											// Use a custom function to determine the string
											switch(langProp.stringSpecial) {
												case 'itemDesc':
													langString = itemDesc(target);
													break;
												case 'passiveDesc':
													langString = passiveDesc(target);
													break;
												case 'spAttDesc':
													langString = spAttDesc(target);
													break;
											}
										}
										else {
											langString = this.getLangString(langCategoryKey, langIdent);
										}
										target[langPropID] = langString;
										if (this.debugMode) {
											if (langString !== undefined) {
												console.debug('Set value of property ' + langPropID + ' for ' + langIdent + ' in node ' + nodeName + ' to: ' + langString);
											}
											else {
												console.debug('No translation: property ' + langPropID + ' for ' + langIdent + ' in node ' + nodeName);
											}
										}
									}
								}
							});
						});
					}
				});
			});
		}
	}
	getLangString(key, identifier) {
		const langCat = loadedLangJson[key];
		if (langCat !== undefined) {
			return langCat[identifier];
		}
	}
	getNamespacedID(namespace, ID) {
		if (ID.indexOf(':') > 0) {
			return ID;
		}
		else {
			return namespace + ':' + ID;
		}
	}
	getLocalID(ID) {
		if (ID.indexOf(':') > 0) {
			return ID.split(':').pop();
		}
		else {
			return ID;
		}
	}
}

let wd = new Wiki;
wd.printWikiData();