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 | ||
class Wiki { | 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); | 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 | // Find the appropriate skill object and combine properties with that | ||
categoryData.forEach((skillData) => { | categoryData.forEach((skillData) => { | ||
Line 506: | Line 432: | ||
}); | }); | ||
}); | }); | ||
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) { | 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; | ||
} | } | ||
// 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; | 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:
- Navigate to https://melvoridle.com within your preferred web browser
- 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
- 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
- Open the browser console/developer mode (usually by hitting the F12 key for most browsers)
- Within the browser console, enter the following code then hit enter. If successful, the game data should appear within the console
- 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(); |