Module:GameData/doc

< Module:GameData
Revision as of 19:40, 26 October 2022 by Auron956 (talk | contribs) (Fix for item names sometimes containing underscores)

This is the documentation page for Module:GameData

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:
// Special attack description generation
// Handle modifications portion of data packages
class Wiki {
    constructor() {
        this.getLocalID = this.getLocalID.bind(this);
        this.namespaces = {
            melvorD: { displayName: "Demo", url: "https://melvoridle.com/assets/data/melvorDemo.json" },
            melvorF: { displayName: "Full Version", url: "https://melvoridle.com/assets/data/melvorFull.json" },
            melvorTotH: { displayName: "Throne of the Herald", url: "https://melvoridle.com/assets/data/melvorTotH.json" }
        };
        this.packData = {};
        this.gameData = {};
        this.skillDataInit = {};
        this.dataPropFilters = {
            // Specifies rules for properties of entities (items, monsters, etc.) which
            // will be removed as they are unused in the wiki & would otherwise bloat
            // the data.
            // Format: property: ruleFunction(entityType, entity)
            //      where ruleFunction is a function returning true if the property is to
            //      be retained, false otherwise
            media: function(entityType, entity) { return false; },
            altMedia: function(entityType, entity) { return false; },
            markMedia: function(entityType, entity) { return false; },
            icon: function(entityType, entity) { return false; },
            barStyle: function(entityType, entity) { return false; }, // See: melvorD:Compost
            buttonStyle: function(entityType, entity) { return false; },
            descriptionGenerator: function(entityType, entity) { return false; },
            containerID: function(entityType, entity) { return false; },
            headerBgClass: function(entityType, entity) { return false; },
            textClass: function(entityType, entity) { return false; },
            btnClass: function(entityType, entity) { return false; },
            golbinRaidExclusive: function(entityType, entity) { return entity.golbinRaidExclusive; },
            ignoreCompletion: function(entityType, entity) { return entity.ignoreCompletion; },
            obtainFromItemLog: function(entityType, entity) { return entity.obtainFromItemLog; },
            validSlots: function(entityType, entity) { return entity.validSlots.length > 0; },
            occupiesSlots: function(entityType, entity) { return entity.occupiesSlots.length > 0; },
            equipRequirements: function(entityType, entity) { return entity.equipRequirements.length > 0; },
            equipmentStats: function(entityType, entity) { return entity.equipmentStats.length > 0; },
            tier: function(entityType, entity) {
                if (entityType === 'items') {
                    return entity.tier !== 'none';
                }
                else {
                    return true;
                }
            }
        };
        this.dataPropTransforms = {
            // Specifies rules for transforming values of entity properties
            // Format: property: ruleFunction(entityType, entity)
            //      where ruleFunction is a function returning the transformed value
            //      to be used in place of the original value
            langHint: function(ns, entityType, entity) { 
                let localID = ''
                if (entity.id.indexOf(':') > 0) {
                    localID = entity.id.split(':').pop();
                }
                else {
                    localID = entity.id
                }
                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();
        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 'steamAchievements':
                    // 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 transformFunc = this.dataPropTransforms[nodeKey];
        if (transformFunc !== undefined) {
            // A transformation function is defined for this node
            parentNode[nodeKey] = transformFunc(ns, categoryName, dataNode);
            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
                const filterFunc = this.dataPropFilters[key];
                if (filterFunc !== undefined && !filterFunc(categoryName, dataNode)) {
                    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;
                        case 'name':
                            // Some items have underscores in their names, replace with spaces
                            if (categoryName == 'items') {
                                dataNode[key] = dataNode[key].replaceAll('_', ' ');
                            }
                            break;
                    }
                }
            });
        }
        // 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) {
                    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.baseGemChance = 1;
                        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'},
                        ];
                        townKeys.forEach((k) => dataNode.data[k.to] = gameSkill[k.from]);
                        break;
                }
                if (importKeys.length > 0) {
                    importKeys.forEach((k) => dataNode.data[k] = gameSkill[k]);
                }
            }
            this.skillDataInit[dataNode.skillID] = 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
                    if (this.gameData[categoryName] === undefined) {
                        this.gameData[categoryName] = [];
                    }
                    categoryData.forEach((orderData) => {
                        switch(orderData.insertAt) {
                            case 'Start':
                                this.gameData[categoryName].splice(0, 0, ...orderData.ids);
                                break;
                            case 'End':
                                this.gameData[categoryName].push(...orderData.ids);
                                break;
                            case 'Before':
                                const beforeIdx = this.gameData[categoryName].findIndex((item) => item === orderData.beforeID);
                                if (beforeIdx === -1) {
                                    throw new Error(`Couldn't insert before in category ${ categoryName }: Item ${ orderData.beforeID } is not in the array.`);
                                }
                                this.gameData[categoryName].splice(beforeIndex, 0, ...orderData.ids);
                                break;
                            case 'After':
                                const afterIdx = this.gameData[categoryName].findIndex((item) => item === orderData.afterID);
                                if (afterIdx === -1) {
                                    throw new Error(`Couldn't insert after in category ${ categoryName }: Item ${ orderData.afterID } is not in the array.`);
                                }
                                this.gameData[categoryName].splice(afterIdx + 1, 0, ...orderData.ids);
                                break;
                        }
                    });
                    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] = categoryData;
                    }
                    else {
                        // Find the appropriate skill object and combine properties with that
                        categoryData.forEach((skillData) => {
                            const skillIdx = this.gameData[categoryName].findIndex((skill) => skill.skillID === skillData.skillID);
                            if (skillIdx === -1) {
                                this.gameData[categoryName].push(skillData);
                            }
                            else {
                                const skillObj = this.gameData[categoryName][skillIdx].data;
                                Object.keys(skillData.data).forEach((dataKey) => {
                                    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) {
            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;
        }
    }
    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();