Module:Sandbox/GameData

From Melvor Idle
Revision as of 12:53, 12 April 2023 by Auron956 (talk | contribs) (Update testing)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

The GameData module is the source of all game data which many other Lua modules rely upon. This module deals with the initial loading of the game data structure, and then enables other modules to access this both via a library of functions (preferred) and in its raw format.

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:Sandbox/GameData/data, Module:Sandbox/GameData/data2, Module:Sandbox/GameData/data3 accordingly
Code
class Wiki {
	constructor() {
		this.debugMode = false;
		this.prettyPrint = false;
		this.baseDir = '/assets/data/';
		this.namespaces = {
			melvorD: { displayName: 'Demo', url: 'https://' + location.hostname + this.baseDir + 'melvorDemo.json' },
			melvorF: { displayName: 'Full Version', url: 'https://' + location.hostname + this.baseDir + 'melvorFull.json' },
			melvorTotH: {
				displayName: 'Throne of the Herald',
				url: 'https://' + location.hostname + this.baseDir + 'melvorTotH.json',
			},
			melvorAoD: {
				displayName: 'Atlas of Discovery',
				url: 'https://' + location.hostname + this.baseDir + 'melvorExpansion2.json',
			},
			melvorBirthday2023: {
				displayName: 'Melvor Birthday 2023',
				url: 'https://' + location.hostname + this.baseDir + 'melvorBirthday2023.json',
			},
			melvorAprilFools2024: {
				displayName: 'Melvor April Fools 2024',
				url: 'https://' + location.hostname + this.baseDir + 'melvorAprilFools2024.json',
			},
			melvorItA: {
				displayName: 'Into the Abyss',
				url: 'https://' + location.hostname + this.baseDir + 'melvorItA.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.`
				);
			}
		});
		// The output data is now long enough that it exceeds the maximum allowed MediaWiki article
		// length of 2,048KB. The below determines how the data should be separated over multiple
		// pages (Module:GameData then combines the data into a single structure upon
		// initialization).
		this.maxPageBytes = 2 * 1024 ** 2; // 2048KB
		this.printPages = [
			{ includeCategories: '*', destination: 'Module:GameData/data' },
			{
				includeCategories: ['items'],
				destination: 'Module:GameData/data2',
			},
			{
				includeCategories: [
					'itemUpgrades',
					'itemSynergies',
					'modifiers',
					'shopPurchases',
					'realms',
					'damageTypes',
					'combatTriangleSets',
					'randomAbyssalGems',
					'randomFragments',
					'randomFiremakingOils',
					'ancientRelics',
					'attackSpells',
					'attacks',
					'combatPassives',
					'monsters',
					'bankSortOrder',
					'combatEffects',
					'combatEffectTemplates',
					'combatEffectGroups'
				],
				destination: 'Module:GameData/data3',
			},
		];

		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 + ')';
	}
	getObjectByID(data, objectID, idKey = 'id') {
		if (data !== undefined && objectID !== undefined) {
			return data.find((obj) => obj[idKey] === objectID);
		}
	}
	getCategoriesForPage(page) {
		if (Array.isArray(page.includeCategories)) {
			return page.includeCategories;
		} else if (page.includeCategories === '*') {
			// Special value, include all categories other than those included within
			// other pages
			return Object.keys(this.gameData).filter(
				(cat) => !this.printPages.some((p) => Array.isArray(p.includeCategories) && p.includeCategories.includes(cat))
			);
		}
	}
	escapeQuotes(data) {
		var newData = data.replace(/\\/g, '\\\\');
		newData = newData.replace(/'/g, "\\'");
		newData = newData.replace(/"/g, '\\"');
		return newData;
	}
	formatJSONData(category, data) {
		if (data === undefined) {
			console.warn(`dataFormatter: Data for category ${category} is undefined`);
			return '';
		}
		if (this.debugMode) {
			console.debug('Formatting category data: ' + category);
		}
		if (category === 'skillData') {
			return '"' + category + '":[' + data.map((x) => this.escapeQuotes(JSON.stringify(x))).join(",' ..\n'") + ']';
		} else {
			return '"' + category + '":' + this.escapeQuotes(JSON.stringify(data));
		}
	}
	dataFullyLoaded() {
		return Object.keys(this.packData).length >= Object.keys(this.namespaces).length;
	}
	printCategoryDataLength() {
		if (!this.dataFullyLoaded()) {
			throw new Error('Game data not loaded, use printWikiData first');
		}
		let dataLengths = [];
		this.printPages.forEach((page) => {
			const inclCat = this.getCategoriesForPage(page);
			inclCat.forEach((cat) => {
				dataLengths.push({
					page: page.destination,
					category: cat,
					length: this.formatJSONData(cat, this.gameData[cat]).length,
				});
			});
		});
		console.table(dataLengths);
	}
	async printWikiData() {
		if (!isLoaded) {
			throw new Error('Game must be loaded into a character first');
		}
		if (!this.dataFullyLoaded()) {
			// Need to retrieve game data first
			const result = await this.getWikiData();
		}
		console.log('Printing data for game version ' + this.getGameVersion());
		this.printPages.forEach((page) => {
			const inclCat = this.getCategoriesForPage(page);
			let gameDataFiltered = {};
			inclCat.forEach((cat) => (gameDataFiltered[cat] = this.gameData[cat]));

			// Convert game data into a JSON string for export
			let dataText;
			if (this.prettyPrint) {
				dataText = JSON.stringify(gameDataFiltered, undefined, '\t');
			} else {
				dataText = JSON.stringify(gameDataFiltered);
			}

			console.log(`For page "${page.destination}" (${dataText.length.toLocaleString()} bytes):`);
			if (dataText.length > this.maxPageBytes) {
				console.warn(
					`Page "${page.destination}" exceeds max page size of ${(this.maxPageBytes / 1024).toLocaleString()}KB by ${(
						dataText.length - this.maxPageBytes
					).toLocaleString()} bytes. Consider amending the printPages configuration to move some data categories from this page onto other pages.`
				);
			}
			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 '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
							const id = dataNode[key];
							if (!Number.isInteger(id)) 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
			const gameSkill = game.skills.getObjectByID(dataNode.skillID);
			// 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] = this.getLangString('MASTERY_CHECKPOINT', `${localID}_${idx}`);
				});
			}
			if (!this.skillDataInit[dataNode.skillID]) {
				if (gameSkill !== undefined) {
					// Import other attributes varying by skill
					let importKeys = [];
					switch (dataNode.skillID) {
						case 'melvorD:Mining':
							importKeys = ['baseInterval', 'baseRockHP', 'passiveRegenInterval'];
							dataNode.data.baseGemChance = 1;
							dataNode.data.rockTypes = loadedLangJson.MINING_TYPE;
							break;
						case 'melvorItA:Harvesting':
							importKeys = [
								'baseInterval',
								'baseVeinIntensity',
								'passiveRegenInterval',
								'uniqueProductChance',
								'hpCheckpoints',
							];
							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: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',
								'abyssalModifierLevels',
								'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: 'BASE_STORAGE', to: 'baseStorage' },
								{ from: 'BASE_TAX_RATE', to: 'baseTaxRate' },
								{ from: 'DECREASED_BUILDING_COST_CAP', to: 'decreasedBuildingCostCap' },
								{ from: 'GP_PER_CITIZEN', to: 'gpPerCitizen' },
								{ from: 'MAX_WORSHIP', to: 'maxWorship' },
								{ from: 'MINIMUM_HEALTH', to: 'minimumHealth' },
								{ from: 'populationForTier', to: 'populationForTier' },
								{ from: 'TICK_LENGTH', to: 'tickLength' },
								{ from: 'RARE_SEASON_CHANCE', to: 'rareSeasonChance' },
								{ from: 'WORSHIP_CHANGE_COST', to: 'worshipChangeCost' },
								{ from: 'WORSHIP_CHECKPOINTS', to: 'worshipCheckpoints' },
								{ from: 'BASE_MAX_HEALTH', to: 'baseMaxHealth' },
								{ from: 'abyssalTierRequirements', to: 'abyssalTierRequirements' },
								{ from: 'BASE_SOUL_STORAGE', to: 'baseSoulStorage' },
							];
							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((category) => ({
								id: category.id,
								name: category.name,
							}));
							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 'realms':
				case 'attackSpellbooks':
				case 'damageTypes':
				case 'equipmentSlots':
				case 'combatAreaCategories':
				case 'combatEffects':
				case 'combatEffectGroups':
				case 'combatEffectTables':
				case 'combatEffectTemplates':
				case 'combatTriangleSets':
				case 'masterPoolBonuses':
				case 'masteryLevelUnlocks':
				case 'masteryLevelBonuses':
				case 'masterPoolBonuses':
				case 'ancientRelics':
				case 'attackSpells':
				case 'attackStyles':
				case 'attacks':
				case 'auroraSpells':
				case 'combatAreas':
				case 'combatEvents':
				case 'combatPassives':
				case 'curseSpells':
				case 'dungeons':
				case 'strongholds':
				case 'abyssDepths':
				case 'gamemodes':
				case 'itemEffects':
				case 'itemSynergies':
				case 'itemUpgrades':
				case 'itmMonsters':
				case 'items':
				case 'lore':
				case 'modifiers':
				case 'monsters':
				case 'pages':
				case 'pets':
				case 'prayers':
				case 'randomGems':
				case 'randomSuperiorGems':
				case 'randomAbyssalGems':
				case 'randomFragments':
				case 'randomFiremakingOils':
				case 'shopCategories':
				case 'shopPurchases':
				case 'shopUpgradeChains':
				case 'skillLevelCapIncreases':
				case 'slayerAreas':
				case 'slayerTaskCategories':
				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 'ancientRelicsDisplayOrder':
				case 'bankSortOrder':
				case 'combatAreaCategoryOrder':
				case 'combatAreaDisplayOrder':
				case 'dungeonDisplayOrder':
				case 'shopCategoryOrder':
				case 'shopDisplayOrder':
				case 'skillTreesDisplayOrder':
				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) => {
							// Special case for Township item conversions
							if (
								skillObj[dataKey] !== undefined &&
								skillData.skillID === 'melvorD:Township' &&
								dataKey === 'itemConversions'
							) {
								Object.keys(skillData.data[dataKey]).forEach((convKey) => {
									skillData.data[dataKey][convKey].forEach((resource) => {
										// Find the resource if it already exists within the combined data
										const resourceIdx = skillObj[dataKey][convKey].findIndex(
											(res) => res.resourceID === resource.resourceID
										);
										if (resourceIdx === -1) {
											skillObj[dataKey][convKey].push(resource);
										} else {
											skillObj[dataKey][convKey][resourceIdx].items.push(...resource.items);
										}
									});
								});
							} else 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;
			}
		});
		// If the data package contains modifications, apply these also
		const modificationData = this.packData[namespace].modifications;
		if (modificationData !== undefined) {
			this.applyDataModifications(modificationData);
		}
		const dependentData = this.packData[namespace].dependentData;
		if (dependentData !== undefined) {
			// TODO Handle dependentData
		}
	}
	applyDataModifications(modData) {
		// TODO: Handle modifications for the following:
		// equipmentSlots - Currently tweaks passive slot requirements
		// pages - Not so important, this is unused data
		// damageTypes
		// combatAreaCategories - Looks a lot like ordered data?
		// skillData - Adjusts Township season modifiers to include ItA resources
		const modDataKeys = Object.keys(modData);
		for (const modCatID in modDataKeys) {
			const modCat = modDataKeys[modCatID];
			const catData = modData[modCat];
			if (modCat === 'shopUpgradeChains' || modCat === 'shopPurchases') {
				// Modify the root upgrade ID of shop upgrade chains, and modify attributes of shop purchases
				catData.forEach((modItem) => {
					const modObjID = modItem.id;
					if (modObjID === undefined) {
						console.warn(
							`Could not apply data modification: ID of object to be modified not found, category "${modCat}"`
						);
					} else {
						const modObj = this.getObjectByID(this.gameData[modCat], modObjID);
						if (modObj === undefined) {
							console.warn(
								`Could not apply data modification: Object with ID "${modObjID}" not found for category "${modCat}"`
							);
						} else {
							const overrideKeys = {
								purchaseRequirements: {
									sourceKey: 'newRequirements', // Key that holds the data in the data package
									destKey: 'purchaseRequirementsOverrides', // Key to insert into within this.gameData
									subKey: 'requirements', // Sub-key containing the override data
								},
								cost: {
									sourceKey: 'newCosts',
									destKey: 'costOverrides',
									subKey: 'cost',
								},
							};
							Object.keys(modItem)
								.filter((k) => k !== 'id')
								.forEach((k) => {
									const overrideKey = overrideKeys[k];
									if (overrideKey !== undefined) {
										// Is an override specific to a gamemode, do not replace
										// the key's existing data
										const destKey = overrideKey.destKey;
										if (modObj[destKey] === undefined) {
											modObj[destKey] = [];
										}
										modItem[k].forEach((gamemodeOverride) => {
											var newData = {};
											newData.gamemodeID = gamemodeOverride.gamemodeID;
											newData[overrideKey.subKey] = gamemodeOverride[overrideKey.sourceKey];
											modObj[destKey].push(newData);
										});
									} else {
										modObj[k] = modItem[k];
									}
								});
						}
					}
				});
			} else if (modCat === 'cookingCategories') {
				// Append to the list of shop upgrade IDs for cooking utilities/categories
				catData.forEach((modItem) => {
					const modObjID = modItem.id;
					const cookingSkill = this.getObjectByID(this.gameData.skillData, 'melvorD:Cooking', 'skillID');
					if (modObjID === undefined) {
						console.warn(
							`Could not apply data modification: ID of object to be modified not found, category "${modCat}"`
						);
					} else if (cookingSkill === undefined) {
						console.warn('Could not apply data modification: Data for skill "melvorD:Cooking" not found');
					} else {
						const modObj = this.getObjectByID(cookingSkill.data.categories, modObjID);
						if (modObj === undefined) {
							console.warn(
								`Could not apply data modification: Object with ID "${modObjID}" not found for category "${modCat}"`
							);
						} else {
							Object.keys(modItem)
								.filter((k) => k !== 'id')
								.forEach((k) => {
									if (k === 'shopUpgradeIDs') {
										if (modObj[k] === undefined) {
											modObj[k] = modItem[k];
										} else {
											modObj[k].push(...modItem[k]);
										}
									} else {
										console.warn(
											`Could not apply data modification: Unhandled key "${k}" for category "${modCat}", object "${mobObjID}"`
										);
									}
								});
						}
					}
				});
			} else if (modCat === 'fletchingRecipes') {
				// Append to alternativeCosts property of recipes (e.g. Arrow shafts)
				catData.forEach((modItem) => {
					const modObjID = modItem.id;
					const fletchingSkill = this.getObjectByID(this.gameData.skillData, 'melvorD:Fletching', 'skillID');
					if (modObjID === undefined) {
						console.warn(
							`Could not apply data modification: ID of object to be modified not found, category "${modCat}"`
						);
					} else if (fletchingSkill === undefined) {
						console.warn('Could not apply data modification: Data for skill "melvorD:Fletching" not found');
					} else {
						const modObj = this.getObjectByID(fletchingSkill.data.recipes, modObjID);
						if (modObj === undefined) {
							console.warn(
								`Could not apply data modification: Object with ID "${modObjID}" not found for category "${modCat}"`
							);
						} else {
							Object.keys(modItem)
								.filter((k) => k !== 'id')
								.forEach((k) => {
									if (k === 'alternativeCosts') {
										if (modObj[k] === undefined) {
											modObj[k] = modItem[k];
										} else {
											modObj[k].push(...modItem[k]);
										}
									} else {
										console.warn(
											`Could not apply data modification: Unhandled key "${k}" for category "${modCat}", object "${mobObjID}"`
										);
									}
								});
						}
					}
				});
			} else if (modCat === 'dungeons') {
				catData.forEach((modItem) => {
					const modObjID = modItem.id;
					if (modObjID === undefined) {
						console.warn(
							`Could not apply data modification: ID of object to be modified not found, category "${modCat}"`
						);
					} else {
						const modObj = this.getObjectByID(this.gameData.dungeons, modObjID);
						if (modObj === undefined) {
							console.warn(
								`Could not apply data modification: Object with ID "${modObjID}" not found for category "${modCat}"`
							);
						} else {
							Object.keys(modItem)
								.filter((k) => k !== 'id')
								.forEach((k) => {
									if (k === 'gamemodeRewardItemIDs') {
										// Add gamemode specific item rewards to dungeon data
										const itemRules = modItem[k];
										Object.keys(itemRules).forEach((ruleKey) => {
											if (ruleKey === 'add') {
												if (modObj[k] === undefined) {
													modObj[k] = [];
												}
												itemRules[ruleKey].forEach((itemDef) => {
													let gamemodeRewards = this.getObjectByID(modObj[k], itemDef.gamemodeID, 'gamemodeID');
													if (gamemodeRewards === undefined) {
														modObj[k].push({
															gamemodeID: itemDef.gamemodeID,
															itemIDs: itemDef.rewardItemIDs,
														});
													} else {
														gamemodeRewards.push(...itemDef.rewardItemIDs);
													}
												});
											} else {
												console.warn(
													`Could not apply data modification: Unknown rule for gamemode item rewards: "${ruleKey}", object "${modObjID}"`
												);
											}
										});
									} else if (k === 'gamemodeEntryRequirements') {
										// Add or remove gamemode specific entry requirements to dungeon data
										if (modObj[k] === undefined) {
											modObj[k] = [];
										}
										modObj[k].push(modItem[k]);
									} else {
										console.warn(
											`Could not apply data modification: Unhandled key "${k}" for category "${modCat}", object "${modObjID}"`
										);
									}
								});
						}
					}
				});
			} else if (modCat === 'modifiers') {
				catData.forEach((modItem) => {
					const modObjID = modItem.id;
					if (modObjID === undefined) {
						console.warn(
							`Could not apply data modification: ID of object to be modified not found, category "${modCat}"`);
					} else {
						// Find modifier definition
						const modParentObj = this.getObjectByID(this.gameData.modifiers, modObjID);
						if ((modParentObj === undefined) || (modParentObj.allowedScopes === undefined)) {
							console.warn(`Could not apply data modification: Modifier with ID ${modObjID} not found or modifier has no scopes`);
						} else {
							modItem.allowedScopes.forEach((srcScope) => {
								// Find scope within modifier modParentObj with matching scopes definition
								const srcScopeKeys = Object.keys(srcScope.scopes);
								modParentObj.allowedScopes.forEach((destScope) => {
									const destScopeKeys = Object.keys(destScope.scopes);
									const scopeMatch = (
										srcScopeKeys.length === destScopeKeys.length
										&& srcScopeKeys.every((k) => destScope.scopes[k] !== undefined && srcScope.scopes[k] == destScope.scopes[k])
									);
									if (scopeMatch) {
										// Scopes match - add aliases to modifier allowedScope definition
										const aliasKeys = ['posAliases', 'negAliases'];
										aliasKeys.forEach((aliasKey) => {
											if (srcScope[aliasKey] !== undefined) {
												if (destScope[aliasKey] === undefined) {
													destScope[aliasKey] = [];
												}
												destScope[aliasKey].push(...srcScope[aliasKey]);
											}
										});
									}
								});
							});
						}
					}
				});
			} else {
				console.warn(`Could not apply data modification: Unhandled category "${modCat}"`);
			}
		}
	}
	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.currencies === undefined) {
			this.gameData.currencies = game.currencies.allObjects.map((c) => ({
				id: c.id,
				name: c.name,
				type: c.type
			}));
		}
		// Melvor realm exists outside of data packages
		if (this.gameData.realms === undefined) {
			this.gameData.realms = game.realms
				.filter((r) => r.id === 'melvorD:Melvor')
				.map((r) => ({
					id: r.id,
					name: r.name,
					unlockRequirements: r.unlockRequirements
				}));
		}
		// Normal damage type exisst outside of data packages
		if (this.gameData.damageTypes === undefined) {
			this.gameData.damageTypes =  game.damageTypes
				.filter((d) => d.id === 'melvorD:Normal')
				.map((d) => ({
					id: d.id,
					name: d.name,
					resistanceCap: d._resistanceCap,
					resistanceName: d.resistanceName
				}));
		}
		/**
		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 = CombatAreaMenuElement.difficulty.map((i) => i.name);
		}
		/**
		if (this.gameData.equipmentSlots === undefined) {
			const slotIDs = Object.keys(EquipmentSlots).filter((slotID) => !isNaN(parseInt(slotID)));
			this.gameData.equipmentSlots = slotIDs.map((slotID) => ({
				id: EquipmentSlots[slotID],
				name: this.getLangString('EQUIP_SLOT', slotID),
			}));
		}
		*/
		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;
		}
		*/
		/**
		if (this.gameData.modifierData === undefined && modifierData !== undefined) {
			var wikiModData = {};
			Object.keys(modifierData).forEach((modK) => {
				const mod = modifierData[modK];
				wikiModData[modK] = {};
				Object.keys(mod).forEach((k) => {
					if (k === 'modifyValue') {
						// Convert function into function name
						// If the function is inline and has no name, then use the function definition instead
						var funcName = mod[k].name;
						if (funcName === 'modifyValue') {
							funcName = mod[k].toString();
						}
						wikiModData[modK][k] = funcName;
					} else if (k === 'langDescription') {
						wikiModData[modK]['description'] = mod[k];
					} else if (k !== 'description') {
						wikiModData[modK][k] = mod[k];
					}
				});
			});
			this.gameData.modifierData = wikiModData;
		}
		*/
	}
	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(beforeIdx, 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 this.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 = (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 = (data) => {
			const item = game.items.getObjectByID(data.id);
			if (item !== undefined && item.hasDescription) {
				return item.description;
			} else return '';
		};
		const shopPurchaseDesc = (data) => {
			const purchase = game.shop.purchases.getObjectByID(data.id);
			if (purchase !== undefined) {
				return purchase.description;
			} else return '';
		};
		const relicDesc = (data) => {
			const relic = game.ancientRelics.getObjectByID(data.id);
			if (relic !== undefined) {
				return relic.name;
			}
		};
		const passiveDesc = (data) => {
			const passive = game.combatPassives.getObjectByID(data.id);
			if (passive !== undefined) {
				return passive.description;
			}
		};
		const spAttDesc = (data) => {
			const spAtt = game.specialAttacks.getObjectByID(data.id);
			if (spAtt !== undefined) {
				return spAtt.description;
			}
		};
		const tsWorshipName = (data) => {
			const worship = game.township.worships.getObjectByID(data.id);
			if (worship !== undefined) {
				return worship.name;
			}
		};
		const tsWorshipStatueName = (data) => {
			const worship = game.township.worships.getObjectByID(data.id);
			if (worship !== undefined) {
				return worship.statueName;
			}
		};
		const attackSpellbooksName = (data) => {
			const book = game.attackSpellbooks.getObjectByID(data.id);
			if (book !== undefined) {
				return book.name;
			}
		};
		const attackSpellName = (data) => {
			const spell = game.attackSpells.getObjectByID(data.id);
			if (spell !== undefined) {
				return spell.name;
			}
		};
		const hasNoLangData = [
			// Categories that contain no localized text. Supresses warnings about no lang data
			'ancientRelicsDisplayOrder',
			'bankSortOrder',
			'combatAreaDisplayOrder',
			'combatAreaCategoryOrder',
			'combatEffectTemplates',
			'combatEvents',
			'dungeonDisplayOrder',
			'golbinRaid',
			'itemEffects',
			'itemSynergies',
			'itemUpgrades',
			'itmMonsters',
			'modifiers', // TODO Does have lang data, supressing warning for now
			'randomAbyssalGems',
			'randomFiremakingOils',
			'randomFragments',
			'randomGems',
			'randomSuperiorGems',
			'slayerAreaDisplayOrder',
			'slayerTaskCategories', // TODO Does have lang data, supressing warning for now
			'shopCategoryOrder',
			'shopDisplayOrder',
			'skillLevelCapIncreases',
			'skillTreesDisplayOrder',
			'spiderLairMonsters',
			'stackingEffects',
		];
		const langKeys = {
			realms: {
				name: { key: 'REALM', idFormat: 'NAME_{ID}' },
			},
			damageTypes: {
				name: { idFormat: 'DAMAGE_TYPE_{ID}' },
			},
			combatTriangleSets: {
				name: { key: 'COMBAT_TRIANGLE_NAME', idFormat: 'NAME_{ID}' },
			},
			attackSpellbooks: {
				name: { stringSpecial: 'attackSpellbooksDesc' },
			},
			attackSpells: {
				name: { stringSpecial: 'attackSpellDesc' },
			},
			ancientRelics: {
				name: { stringSpecial: 'relicDesc' },
			},
			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}' },
			},
			combatAreaCategories: {
				name: { key: 'COMBAT_AREA_CATEGORY' }
			},
			combatAreas: {
				name: { key: 'COMBAT_AREA', idFormat: 'NAME_{ID}' },
			},
			combatEffectGroups: {
				name: { idKey: 'nameLang' }
			},
			combatEffects: {
				name: { idKey: 'nameLang' }
			},
			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}' },
			},
			abyssDepths: {
				name: { key: 'THE_ABYSS', idFormat: 'NAME_{ID}' },
			},
			strongholds: {
				name: { key: 'STRONGHOLD_NAME', 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: false },
			},
			lore: {
				title: { key: 'LORE', idFormat: 'TITLE_{ID}' },
			},
			monsters: {
				name: { key: 'MONSTER_NAME' },
				description: { key: 'MONSTER_DESCRIPTION' },
			},
			pets: {
				name: { key: 'PET_NAME' },
				hint: { idKey: 'langHint' }
			},
			prayers: {
				name: { key: 'PRAYER', idFormat: 'PRAYER_NAME_{ID}' },
			},
			shopCategories: {
				name: { key: 'SHOP_CAT' },
			},
			shopPurchases: {
				customName: { key: 'SHOP_NAME', onlyIfExists: true },
				customDescription: { stringSpecial: 'shopPurchaseDesc', onlyIfExists: false },
			},
			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}' },
			},
			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}' },
					},
				},
				Archaeology: {
					digSites: {
						name: { key: 'POI_NAME_Melvor' },
					},
					// TODO Tool names
				},
				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}' },
					},
				},
				Cartography: {
					mapPortals: { _handler: 'mapPortals' },
					travelEvents: {
						description: { key: 'TRAVEL_EVENT' },
					},
					worldMaps: { _handler: 'cartoMaps' },
					//name: { key: 'WORLD_MAP_NAME' },
					//pointsOfInterest: {	_handler: 'mapPOI' }
					//name: { key: 'POI_NAME', idFormat: '{MAPID}_{ID}' },
					//description: { key: 'POI_DESCRIPTION', idFormat: '{MAPID}_{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: { stringSpecial: 'tsWorshipName' },
						statueName: { stringSpecial: 'tsWorshipStatueName' },
					},
				},
				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 if (!hasNoLangData.includes(nodeKey)) {
			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) => {
							const handlerFunc = langKeyData[langKey]['_handler'];
							if (handlerFunc !== undefined) {
								switch (handlerFunc) {
									case 'mapPortals':
										Object.keys(target).forEach((portalKey) => {
											let portalData = target[portalKey];
											const langID = this.getLocalID(portalData.originWorldMap) + '_' + this.getLocalID(portalData.id);
											portalData.name = this.getLangString('POI_NAME', langID);
											portalData.description = this.getLangString('POI_DESCRIPTION', langID);
										});
										break;
									case 'cartoMaps':
										// Target represents a world map
										const mapID = this.getLocalID(target.id);
										target.name = this.getLangString('WORLD_MAP_NAME', mapID);
										// Process POIs
										target.pointsOfInterest.forEach((poi) => {
											const langID = mapID + '_' + this.getLocalID(poi.id);
											poi.name = this.getLangString('POI_NAME', langID);
											poi.description = this.getLangString('POI_DESCRIPTION', langID);
										});
										break;
								}
							} else {
								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 'shopPurchaseDesc':
														langString = shopPurchaseDesc(target);
														break;
													case 'passiveDesc':
														langString = passiveDesc(target);
														break;
													case 'relicDesc':
														langString = relicDesc(target);
														break;
													case 'spAttDesc':
														langString = spAttDesc(target);
														break;
													case 'tsWorshipName':
														langString = tsWorshipName(target);
														break;
													case 'tsWorshipStatueName':
														langString = tsWorshipStatueName(target);
														break;
													case 'attackSpellbooksName':
														langString = attackSpellbooksName(target);
														break;
													case 'attackSpellName':
														langString = attackSpellName(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) {
		let lookupVal = '';
		if (key !== undefined) {
			lookupVal = key;
		}
		if (identifier !== undefined) {
			lookupVal += (lookupVal.length > 0 ? '_' : '') + identifier;
		}
		return loadedLangJson[lookupVal];
	}
	getNamespacedID(namespace, ID) {
		if (ID.indexOf(':') > 0) {
			return ID;
		} else {
			return namespace + ':' + ID;
		}
	}
	getLocalID(ID) {
		if (ID !== undefined && ID.indexOf(':') > 0) {
			return ID.split(':').pop();
		} else {
			return ID;
		}
	}
}

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

-- This module is responsible for managing all game data, and provides various
-- functions so that other modules may interact with the data.

local p = {}

local GameData1 = mw.loadData('Module:Sandbox/GameData/data')
local GameData2 = mw.loadData('Module:Sandbox/GameData/data2')
-- Combine data into a single object
local GameData = {}
for _, data in ipairs({GameData1, GameData2}) do
	for entityType, entityData in pairs(data) do
		GameData[entityType] = entityData
	end
end

local indexCache = {}

-- Expose underlying data should other modules require it
p.rawData = GameData

-- Given a namespace & local ID, returns a namespaced ID
function p.getNamespacedID(namespace, localID)
	if string.find(localID, ':') == nil then
		return namespace .. ':' .. localID
	else
		-- ID already appears to be namespaced
		return localID
	end
end

-- Given a namespaced ID, returns both the namespace & local ID
function p.getLocalID(ID)
	local namespace, localID = nil, nil
	local sepIdx = string.find(ID, ':')
	if sepIdx == nil then
		-- Provided ID doesn't appear to be namespaced
		localID = ID
	else
		namespace = string.sub(ID, 1, sepIdx - 1)
		localID = string.sub(ID, sepIdx + 1, string.len(ID))
	end
	return namespace, localID
end

local function populateSkillData()
	local skillData = {}
	for i, skillObj in ipairs(GameData.skillData) do
		local _, localID = p.getLocalID(skillObj.skillID)
        if localID ~= nil then
            skillData[localID] = skillObj.data
        end
	end
    return skillData
end

-- Expose an easy way to reference skill data by skill local ID
p.skillData = populateSkillData()

function p.skillDataTest()
	for localID, data in pairs(p.skillData) do
		mw.log(localID)
	end
end

-- If the entity ID is within the cache for the given entity type, then return it.
-- Otherwise, returns nil
local function getCache(entityType, ID)
	if type(entityType) == 'string' and type(ID) == 'string' then
		local cacheCat = indexCache[entityType]
		if cacheCat ~= nil then
			return cacheCat[ID]
		end
	end
end

-- Sets the cache for entity ID within the given entity type to idx.
local function setCache(entityType, ID, idx)
	if type(entityType) == 'string' and type(ID) == 'string' and type(idx) == 'number' then
		if indexCache[entityType] == nil then
			indexCache[entityType] = {}
		end
		if indexCache[entityType][ID] == nil then
			indexCache[entityType][ID] = math.floor(idx)
		end
	end
end

function p.getSkillData(skillID)
	if type(skillID) ~= 'string' then
		error('Skill ID must be a string', 2)
	end
	for idx, skill in ipairs(GameData.skillData) do
		if skill.skillID == skillID then
			return skill.data
		end
	end
end

-- Takes an entity type (or entity list) & property name/value, returning an object representing the
-- entity with the given property (if found). If not found, then nil is returned.
function p.getEntityByProperty(entityType, propName, propValue)
	if type(entityType) ~= 'string' and type(entityType) ~= 'table' then
		error('Entity type name must be a string or table', 2)
	elseif type(propName) ~= 'string' then
		error('Property name must be a string', 2)
	end
    local entData, useCache = nil, false
    if type(entityType) == 'string' then
        entData = GameData[entityType]
        useCache = true
    else
        -- Function was passed a table of entities rather than a entity type
        entData = entityType
    end
	if entData == nil then
		error('No such entity type: ' .. entityType, 2)
	elseif type(entData) ~= 'table' then
		error('Entity data is not a table: ' .. entityType, 2)
	elseif type(entData[1]) ~= 'table' or entData[1][propName] == nil then
		error('Entity data is not composed of entities: ' .. entityType, 2)
	end
	-- Check if this ID is already cached
	if propName == 'id' and useCache then
		local cacheIdx = getCache(entityType, propValue)
		if cacheIdx ~= nil then
			-- Cache hit
			return entData[cacheIdx]
		end
	end
	-- Cache miss or property isn't ID, so scan the entity data sequentially
	for idx, entity in ipairs(entData) do
        if useCache then
		    setCache(entityType, entity.id, idx)
        end
		if entity[propName] == propValue then
			return entity
		end
	end
end

function p.getEntityByID(entityType, entityID)
	return p.getEntityByProperty(entityType, 'id', entityID)
end

function p.getEntityByName(entityType, entityName)
	return p.getEntityByProperty(entityType, 'name', entityName)
end

-- Takes an entity type and a function, returning a list of entities for which
-- the function evaluates to true.
-- The function must accept one parameter (the entity being checked), and must
-- return either true or false
function p.getEntities(entityType, checkFunc)
	local result = {}
	local entityCount = 0
	if type(entityType) ~= 'string' and type(entityType) ~= 'table' then
		error('Entity type name must be a string or table', 2)
	elseif type(checkFunc) ~= 'function' then
		error('Check function name must be a function', 2)
	end
	local entData, useCache = nil, false
    if type(entityType) == 'string' then
        entData = GameData[entityType]
        useCache = true
    else
        -- Function was passed a table of entities rather than a entity type
        entData = entityType
    end
	if entData == nil and type(entityType) == 'string' then
		error('No such entity type: ' .. entityType, 2)
	elseif type(entData) ~= 'table' then
		error('Entity data is not a table: ' .. entityType, 2)
	elseif type(entData[1]) ~= 'table' then
		error('Entity data is not composed of entities: ' .. entityType, 2)
	end
	for idx, entity in ipairs(entData) do
		if useCache then
			setCache(entityType, entity.id, idx)
		end
		if checkFunc(entity) then
			entityCount = entityCount + 1
			result[entityCount] = entity
		end
	end
	return result
end

-- Sorts the given dataTable by ID into the same order as orderTable
-- keepUnsorted specifies whether unsorted elements are included within the output. Default: true
--  unsorted elements will be at the end of the array, order is not guaranteed
-- idKey specifies the ID key to sort upon. Default: id
-- orderFunc specifies a custom order function if the default behaviour is not desired
-- Example - Sorts combat areas into the same order as displayed in game:
--      p.sortByOrderTable(p.rawData.combatAreas, p.rawData.combatAreaDisplayOrder)
function p.sortByOrderTable(dataTable, orderTable, keepUnsorted, idKey, orderFunc)
    -- Create index table from orderTable
    local orderIdx = {}
    for idx, v in ipairs(orderTable) do
        orderIdx[v] = idx
    end
    -- Determine if user-specified or default paramater values are to be used
    if type(keepUnsorted) ~= 'boolean' then
        keepUnsorted = true
    end
    if type(idKey) ~= 'string' then
        idKey = 'id'
    end
    if type(orderFunc) ~= 'function' then
        orderFunc = function(k1, k2)
            return orderIdx[k1[idKey]] < orderIdx[k2[idKey]]
        end
    end

    -- Build unsorted result table, removing unsorted elements if requested
    local resultTable = {}
    local resultItemCount = 0
    for idx, v in ipairs(dataTable) do
        local keyVal = v[idKey]
        if keyVal ~= nil then
            if keepUnsorted or orderIdx[keyVal] ~= nil then
                resultItemCount = resultItemCount + 1
                resultTable[resultItemCount] = v
            end
        end
    end
    -- Sort table
    table.sort(resultTable, orderFunc)
    return resultTable
end

return p