Module:Monsters

From Melvor Idle
Revision as of 20:56, 7 January 2022 by Auron956 (talk | contribs) (getMonsterPassives: Fix detection for presence of passive abilities)
The printable version is no longer supported and may have rendering errors. Please update your browser bookmarks and please use the default browser print function instead.

Data is pulled from Module:GameData/data


local p = {}

local MonsterData = mw.loadData('Module:Monsters/data')

local Constants = require('Module:Constants')
local Areas = require('Module:CombatAreas')
local Magic = require('Module:Magic')
local Shared = require('Module:Shared')
local Icons = require('Module:Icons')
local Items = require('Module:Items')

function p.getMonster(name)
	local result = nil
	if name == 'Spider (lv. 51)' or name == 'Spider' then
		return p.getMonsterByID(50)
	elseif name == 'Spider (lv. 52)' or name == 'Spider2' then
		return p.getMonsterByID(51)
	end

	for i, monster in pairs(MonsterData.Monsters) do
		if monster.name == name then
			result = Shared.clone(monster)
			--Make sure every monster has an ID, and account for the 1-based indexing of Lua
			result.id = i - 1
			break
		end
	end
	return result
end

function p.getMonsterByID(ID)
	local result = Shared.clone(MonsterData.Monsters[ID + 1])
	result.id = ID
	return result
end

function p.getPassive(name)
	local result = nil

	for i, passive in pairs(MonsterData.Passives) do
		if passive.name == name then
			result = Shared.clone(passive)
			--Make sure every passive has an ID, and account for the 1-based indexing of Lua
			result.id = i - 1
			break
		end
	end
	return result
end

function p.getPassiveByID(ID)
	return MonsterData.Passives[ID + 1]
end

-- Given a list of monster IDs, calls statFunc with each monster and returns
-- the lowest & highest values
function p.getLowHighStat(idList, statFunc)
	local lowVal, highVal = nil, nil
	for i, monID in ipairs(idList) do
		local monster = p.getMonsterByID(monID)
		local statVal = statFunc(monster)
		if lowVal == nil or statVal < lowVal then lowVal = statVal end
		if highVal == nil or statVal > highVal then highVal = statVal end
	end
	return lowVal, highVal
end

function p._getMonsterStat(monster, statName)
	if statName == 'HP' then
		return p._getMonsterHP(monster)
	elseif statName == 'maxHit' then
		return p._getMonsterMaxHit(monster)
	elseif statName == 'accuracyRating' then
		return p._getMonsterAR(monster)
	elseif statName == 'meleeEvasionRating' then
		return p._getMonsterER(monster, 'Melee')
	elseif statName == 'rangedEvasionRating' then
		return p._getMonsterER(monster, 'Ranged')
	elseif statName == 'magicEvasionRating' then
		return p._getMonsterER(monster, 'Magic')
	elseif statName == 'damageReduction' then
		return p.getEquipmentStat(monster, 'damageReduction')
	end

	return monster[statName]
end

function p.getMonsterStat(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame[1]
	local StatName = frame.args ~= nil and frame.args[2] or frame[2]
	local monster = p.getMonster(MonsterName)
	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	return p._getMonsterStat(monster, StatName)
end

function p._getMonsterStyleIcon(frame)
	local args = frame.args ~= nil and frame.args or frame
	local monster = args[1]
	local notext = args.notext
	local nolink = args.nolink

	local iconText = ''
	if monster.attackType == 'melee' then
		iconText = Icons.Icon({'Melee', notext=notext, nolink=nolink})
	elseif monster.attackType == 'ranged' then
		iconText = Icons.Icon({'Ranged', type='skill', notext=notext, nolink=nolink})
	elseif monster.attackType == 'magic' then
		iconText = Icons.Icon({'Magic', type='skill', notext=notext, nolink=nolink})
	elseif monster.attackType == 'random' then
		iconText = Icons.Icon({'Bane', notext=notext, nolink=nolink, img='Question'})
	end

	return iconText
end

function p.getMonsterStyleIcon(frame)
	local args = frame.args ~= nil and frame.args or frame
	local MonsterName = args[1]
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	args[1] = monster
	return p._getMonsterStyleIcon(args)
end

function p._getMonsterHP(monster)
	return 10 * p._getMonsterLevel(monster, 'Hitpoints')
end

function p.getMonsterEffectiveHP(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)
	if monster ~= nil then
		return math.floor((p._getMonsterHP(monster)/(1 - p._getMonsterStat(monster, 'damageReduction')/100)) + 0.5)
	else
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end
end

function p.getMonsterHP(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)
	if monster ~= nil then
		return p._getMonsterHP(monster)
	else
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end
end

function p._getMonsterLevel(monster, skillName)
	local result = 0
	if monster.levels[skillName] ~= nil then
		result = monster.levels[skillName]
	end
	return result
end

function p.getMonsterLevel(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame[1]
	local SkillName = frame.args ~= nil and frame.args[2] or frame[2]
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	return p._getMonsterLevel(monster, SkillName)
end

function p.getEquipmentStat(monster, statName)
	local result = 0
	for i, stat in Shared.skpairs(monster.equipmentStats) do
		if stat.key == statName then
			result = stat.value
			break
		end
	end
	return result
end

function p.calculateStandardStat(effectiveLevel, bonus)
	--Based on calculateStandardStat in Characters.js
	return (effectiveLevel + 9) * (bonus + 64)
end

function p.calculateStandardMaxHit(baseLevel, strengthBonus)
	--Based on calculateStandardMaxHit in Characters.js
	local effectiveLevel = baseLevel + 9
	return math.floor(10 * (1.3 + effectiveLevel / 10 + strengthBonus / 80 + effectiveLevel * strengthBonus / 640))
end

function p._getMonsterAttackSpeed(monster)
	return p.getEquipmentStat(monster, 'attackSpeed') / 1000
end

function p.getMonsterAttackSpeed(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)
	if monster ~= nil then
		return p._getMonsterAttackSpeed(monster)
	else
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end
end

function p._getMonsterCombatLevel(monster)
	local base = 0.25 * (p._getMonsterLevel(monster, 'Defence') + p._getMonsterLevel(monster, 'Hitpoints'))
	local melee = 0.325 * (p._getMonsterLevel(monster, 'Attack') + p._getMonsterLevel(monster, 'Strength'))
	local range = 0.325 * (1.5 * p._getMonsterLevel(monster, 'Ranged'))
	local magic = 0.325 * (1.5 * p._getMonsterLevel(monster, 'Magic'))
	return math.floor(base + math.max(melee, range, magic))
end

function p.getMonsterCombatLevel(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	return p._getMonsterCombatLevel(monster)
end

function p._getMonsterAR(monster)
	local baseLevel = 0
	local bonus = 0
	if monster.attackType == 'melee' then
		baseLevel = p._getMonsterLevel(monster, 'Attack')
		bonus = p.getEquipmentStat(monster, 'stabAttackBonus')
	elseif monster.attackType == 'ranged' then
		baseLevel = p._getMonsterLevel(monster, 'Ranged')
		bonus = p.getEquipmentStat(monster, 'rangedAttackBonus')
	elseif monster.attackType == 'magic' then
		baseLevel = p._getMonsterLevel(monster, 'Magic')
		bonus = p.getEquipmentStat(monster, 'magicAttackBonus')
	elseif monster.attackType == 'random' then
		--Bane has the same AR with every attack type so being lazy and just showing the one.
		baseLevel = p._getMonsterLevel(monster, 'Attack')
		bonus = p.getEquipmentStat(monster, 'stabAttackBonus')
	else
		return "ERROR: This monster has an invalid attack type somehow[[Category:Pages with script errors]]"
	end

	return p.calculateStandardStat(baseLevel, bonus)
end

function p.getMonsterAR(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	return p._getMonsterAR(monster)
end

function p._getMonsterER(monster, style)
	local baseLevel= 0
	local bonus = 0

	if style == "Melee" then
		baseLevel = p._getMonsterLevel(monster, 'Defence')
		bonus = p.getEquipmentStat(monster, 'meleeDefenceBonus')
	elseif style == "Ranged" then
		baseLevel = p._getMonsterLevel(monster, 'Defence')
		bonus = p.getEquipmentStat(monster, 'rangedDefenceBonus')
	elseif style == "Magic" then
		baseLevel = math.floor(p._getMonsterLevel(monster, 'Magic') * 0.7 + p._getMonsterLevel(monster, 'Defence') * 0.3)
		bonus = p.getEquipmentStat(monster, 'magicDefenceBonus')
	else
		return "ERROR: Must choose Melee, Ranged, or Magic[[Category:Pages with script errors]]"
	end

	return p.calculateStandardStat(baseLevel, bonus)
end

function p.getMonsterER(frame)
	local args = frame.args ~= nil and frame.args or frame
	local MonsterName = args[1]
	local style = args[2]
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	return p._getMonsterER(monster, style)
end

-- Determines if the monster is capable of dropping bones, and returns the bones
-- item if so, or nil otherwise
function p.getMonsterBones(monster)
	if monster.bones ~= nil and monster.bones >= 0 then
		local boneItem = Items.getItemByID(monster.bones)
		if boneItem.prayerPoints == nil then
			-- Assume bones without prayer points are shards (from God dungeons),
			-- and drop unconditionally
			return boneItem
		elseif not monster.isBoss and not p._isDungeonOnlyMonster(monster) then
			-- Otherwise, bones drop when the monster isn't dungeon exclusive
			return boneItem
		end
	end
end

function p._isDungeonOnlyMonster(monster)
	local areaList = Areas.getMonsterAreas(monster.id)
	local inDungeon = false

	for i, area in ipairs(areaList) do
		if area.type == 'dungeon' then
			inDungeon = true
		else
			return false
		end
	end
	return inDungeon
end

function p.isDungeonOnlyMonster(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with name "..monsterName.." found[[Category:Pages with script errors]]"
	end

	return p._isDungeonOnlyMonster(monster)
end

function p._getMonsterAreas(monster, excludeDungeons)
	local resultPart = {}
	local hideDungeons = excludeDungeons ~= nil and excludeDungeons or false
	local areaList = Areas.getMonsterAreas(monster.id)
	for i, area in ipairs(areaList) do
		if area.type ~= 'dungeon' or not hideDungeons then
			table.insert(resultPart, Icons.Icon({area.name, type = area.type}))
		end
	end
	return table.concat(resultPart, '<br/>')
end

function p.getMonsterAreas(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local hideDungeons = frame.args ~= nil and frame.args[2] or nil
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with name "..monsterName.." found[[Category:Pages with script errors]]"
	end

	return p._getMonsterAreas(monster, hideDungeons)
end

function p.getSpecAttackMaxHit(specAttack, normalMaxHit)
	local result = 0
	for i, dmg in pairs(specAttack.damage) do
		if dmg.maxRoll == 'Fixed' then
			result = dmg.maxPercent * 10
		elseif dmg.maxRoll == 'MaxHit' then
			if dmg.character == 'Target' then
				--Confusion applied damage based on the player's max hit. Gonna just ignore that one
				result = 0
			else
				result = dmg.maxPercent * normalMaxHit * 0.01
			end
		elseif Shared.contains({'Bleeding', 'Poisoned'}, dmg.maxRoll) then
			-- TODO: This is limited in that there is no verification that bleed/poison
			-- can be applied to the target, it is assumed that it can and so this applies
			result = result + dmg.maxPercent * 10
		end
	end
	return result
end

function p.canSpecAttackApplyEffect(specAttack, effectType)
	local result = false
	for i, effect in pairs(specAttack.prehitEffects) do
		if effect.type == effectType then
			result = true
			break
		end
	end

	for i, effect in pairs(specAttack.onhitEffects) do
		if effect.type == effectType then
			result = true
			break
		end
	end
	return result
end

function p._getMonsterMaxHit(monster, doStuns)
	-- 2021-06-11 Adjusted for v0.20 stun/sleep changes, where damage multiplier now applies
	-- to all enemy attacks if stun/sleep is present on at least one special attack
	if doStuns == nil then
		doStuns = true
	elseif type(doStuns) == 'string' then
		doStuns = string.upper(doStuns) == 'TRUE'
	end

	local normalChance = 100
	local specialMaxHit = 0
	local normalMaxHit = p._getMonsterBaseMaxHit(monster)
	local hasActiveBuffSpec = false
	local damageMultiplier = 1
	if monster.specialAttacks[1] ~= nil then
		local canStun, canSleep = false, false
		for i, specAttack in pairs(monster.specialAttacks) do
			if monster.overrideSpecialChances ~= nil then
				normalChance = normalChance - monster.overrideSpecialChances[i]
			else
				normalChance = normalChance - specAttack.defaultChance
			end
			local thisMax = p.getSpecAttackMaxHit(specAttack, normalMaxHit)
			if not canStun and p.canSpecAttackApplyEffect(specAttack, 'Stun') then canStun = true end
			if not canSleep and p.canSpecAttackApplyEffect(specAttack, 'Sleep') then canSleep = true end

			if thisMax > specialMaxHit then specialMaxHit = thisMax end
			if Shared.contains(string.upper(specAttack.description), 'NORMAL ATTACK INSTEAD') then 
				hasActiveBuffSpec = true 
			end
		end

		if canSleep and doStuns then damageMultiplier = damageMultiplier * 1.2 end
		if canStun and doStuns then damageMultiplier = damageMultiplier * 1.3 end
	end
	--Ensure that if the monster never does a normal attack, the normal max hit is irrelevant
	if normalChance == 0 and not hasActiveBuffSpec then normalMaxHit = 0 end
	return math.floor(math.max(specialMaxHit, normalMaxHit) * damageMultiplier)
end

function p.getMonsterMaxHit(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local doStuns = frame.args ~= nil and frame.args[2] or true
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	return p._getMonsterMaxHit(monster, doStuns)
end

function p._getMonsterBaseMaxHit(monster)
	--8/27/21 - Now references p.calculateStandardMaxHit for Melee & Ranged
	local result = 0
	local baseLevel = 0
	local bonus = 0
	if monster.attackType == 'melee' then
		baseLevel = p._getMonsterLevel(monster, 'Strength')
		bonus = p.getEquipmentStat(monster, 'meleeStrengthBonus')
		result = p.calculateStandardMaxHit(baseLevel, bonus)
	elseif monster.attackType == 'ranged' then
		baseLevel = p._getMonsterLevel(monster, 'Ranged')
		bonus = p.getEquipmentStat(monster, 'rangedStrengthBonus')
		result = p.calculateStandardMaxHit(baseLevel, bonus)
	elseif monster.attackType == 'magic' then
		local mSpell = nil
		if monster.selectedSpell ~= nil then mSpell = Magic.getSpellByID('Spells', monster.selectedSpell) end

		bonus = p.getEquipmentStat(monster, 'magicDamageBonus')
		baseLevel = p._getMonsterLevel(monster, 'Magic')

		result = math.floor(10 * mSpell.maxHit * (1 + bonus / 100) * (1 + (baseLevel + 1) / 200))
	elseif monster.attackType == 'random' then
		local hitArray = {}
		local iconText = Icons.Icon({'Melee', notext=true})
		baseLevel = p._getMonsterLevel(monster, 'Strength')
		bonus = p.getEquipmentStat(monster, 'meleeStrengthBonus')
		table.insert(hitArray, p.calculateStandardMaxHit(baseLevel, bonus))
		
		iconText = Icons.Icon({'Ranged', type='skill', notext=true})
		baseLevel = p._getMonsterLevel(monster, 'Ranged')
		bonus = p.getEquipmentStat(monster, 'rangedStrengthBonus')
		table.insert(hitArray, p.calculateStandardMaxHit(baseLevel, bonus))
		
		iconText = Icons.Icon({'Magic', type='skill', notext=true})
		local mSpell = nil
		if monster.selectedSpell ~= nil then mSpell = Magic.getSpellByID('Spells', monster.selectedSpell) end
		bonus = p.getEquipmentStat(monster, 'magicDamageBonus')
		baseLevel = p._getMonsterLevel(monster, 'Magic')
		local magicDmg = math.floor(10 * mSpell.maxHit * (1 + bonus / 100) * (1 + (baseLevel + 1) / 200))
		table.insert(hitArray, magicDmg)
		
		local max = 0
		for i, val in pairs(hitArray) do
			if val > max then max = val end
		end
		result = max
	else
		return "ERROR: This monster has an invalid attack type somehow[[Category:Pages with script errors]]"
	end

	return result
end

function p.getMonsterBaseMaxHit(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	return p._getMonsterBaseMaxHit(monster)
end

function p.getMonsterAttacks(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	local result = ''
	local iconText = p._getMonsterStyleIcon({monster, notext=true})
	local typeText = ''
	if monster.attackType == 'melee' then
		typeText = 'Melee'
	elseif monster.attackType == 'ranged' then
		typeText = 'Ranged'
	elseif monster.attackType == 'magic' then
		typeText = 'Magic'
	elseif monster.attackType == 'random' then
		typeText = "Random"
	end

	local buffAttacks = {}
	local hasActiveBuffSpec = false

	local normalAttackChance = 100
	if monster.specialAttacks[1] ~= nil then
		for i, specAttack in pairs(monster.specialAttacks) do
			local attChance = 0
			if monster.overrideSpecialChances ~= nil then
				attChance = monster.overrideSpecialChances[i]
			else
				attChance = specAttack.defaultChance
			end
			normalAttackChance = normalAttackChance - attChance

			result = result..'\r\n* '..attChance..'% '..iconText..' '..specAttack.name..'\r\n** '..specAttack.description

			if Shared.contains(string.upper(specAttack.description), 'NORMAL ATTACK INSTEAD') then
				table.insert(buffAttacks, specAttack.name)
				hasActiveBuffSpec = true 
			end
		end
	end
	if normalAttackChance == 100 then
		result = iconText..' 1 - '..p._getMonsterBaseMaxHit(monster)..' '..typeText..' Damage'
	elseif normalAttackChance > 0 then
		result = '* '..normalAttackChance..'% '..iconText..' 1 - '..p.getMonsterBaseMaxHit(frame)..' '..typeText..' Damage'..result
	elseif hasActiveBuffSpec then
		--If the monster normally has a 0% chance of doing a normal attack but some special attacks can't be repeated, include it
		--(With a note about when it does it)
		result = '* '..iconText..' 1 - '..p._getMonsterBaseMaxHit(monster)..' '..typeText..' Damage (Instead of repeating '..table.concat(buffAttacks, ' or ')..' while the effect is already active)'..result
	end

	return result
end

function p.getMonsterPassives(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	local result = ''
	if monster.passiveID ~= nil then
		result = result .. '===Passives==='
		for i, passiveID in pairs(monster.passiveID) do
			local passive = p.getPassiveByID(passiveID)
			result = result .. '\r\n* ' .. passive.name .. '\r\n** ' .. passive.description
		end
	end
	return result
end

function p.getMonsterCategories(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	local result = '[[Category:Monsters]]'

	if monster.attackType == 'melee' then
		result = result..'[[Category:Melee Monsters]]'
	elseif monster.attackType == 'ranged' then
		result = result..'[[Category:Ranged Monsters]]'
	elseif monster.attackType == 'magic' then
		result = result..'[[Category:Magic Monsters]]'
	end

	if monster.specialAttacks[1] ~= nil then
		result = result..'[[Category:Monsters with Special Attacks]]'
	end

	if monster.isBoss then
		result = result..'[[Category:Bosses]]'
	end

	return result
end

function p.getOtherMonsterBoxText(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	local result = ''

	--Going through and finding out which damage bonuses will apply to this monster
	local monsterTypes = {}
	if monster.isBoss then table.insert(monsterTypes, 'Boss') end

	local areaList = Areas.getMonsterAreas(monster.id)
	local counts = {combat = 0, slayer = 0, dungeon = 0}
	for i, area in Shared.skpairs(areaList) do
		counts[area.type] = counts[area.type] + 1
	end

	if counts.combat > 0 then table.insert(monsterTypes, 'Combat Area') end
	if counts.slayer > 0 then table.insert(monsterTypes, 'Slayer Area') end
	if counts.dungeon > 0 then table.insert(monsterTypes, 'Dungeon') end

	result = result.."\r\n|-\r\n|'''Monster Types:''' "..table.concat(monsterTypes, ", ")

	local SlayerTier = 'N/A'
	if monster.canSlayer then
		SlayerTier = Constants.getSlayerTierNameByLevel(p._getMonsterCombatLevel(monster))
	end

	result = result.."\r\n|-\r\n|'''"..Icons.Icon({'Slayer', type='skill'}).." [[Slayer#Slayer Tier Monsters|Tier]]:''' "
	if monster.canSlayer then
		result = result.."[[Slayer#"..SlayerTier.."|"..SlayerTier.."]]"
	else
		result = result..SlayerTier
	end

	return result
end

function p.getMonsterDrops(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	local result = ''

	local bones = p.getMonsterBones(monster)
	--Show the bones only if either the monster shows up outside of dungeons _or_ the monster drops shards
	if bones ~= nil then
		result = result.."'''Always Drops:'''"
		result = result..'\r\n{|class="wikitable" id="bonedrops"'
		result = result..'\r\n!Item !! Qty'
		result = result..'\r\n|-\r\n|'..Icons.Icon({bones.name, type='item'})
		result = result..'||'..(monster.boneQty ~= nil and monster.boneQty or 1)..'\r\n'..'|}'
	end

	--Likewise, seeing the loot table is tied to the monster appearing outside of dungeons
	if not p._isDungeonOnlyMonster(monster) then
		local lootChance = monster.lootChance ~= nil and monster.lootChance or 100
		local lootValue = 0

		result = result.."'''Loot:'''"
		local avgGp = 0

		if monster.dropCoins ~= nil and monster.dropCoins[2] > 1 then
			avgGp = (monster.dropCoins[1] + monster.dropCoins[2]) / 2
			local gpTxt = Icons.GP(monster.dropCoins[1], monster.dropCoins[2])
			result = result.."\r\nIn addition to loot, the monster will also drop "..gpTxt..'.'
		end

		local multiDrop = Shared.tableCount(monster.lootTable) > 1
		local totalWt = 0
		for i, row in pairs(monster.lootTable) do
			totalWt = totalWt + row[2]
		end
		result = result..'\r\n{|class="wikitable sortable" id="itemdrops"'
		result = result..'\r\n!Item!!Qty'
		result = result..'!!Price!!colspan="2"|Chance'

		--Sort the loot table by weight in descending order
		table.sort(monster.lootTable, function(a, b) return a[2] > b[2] end)
		for i, row in Shared.skpairs(monster.lootTable) do
			local thisItem = Items.getItemByID(row[1])
			
			local maxQty = row[3]
			if thisItem ~= nil then
				result = result..'\r\n|-\r\n|'..Icons.Icon({thisItem.name, type='item'})
			else
				result = result..'\r\n|-\r\n|Unknown Item[[Category:Pages with script errors]]'
			end
			result = result..'||style="text-align:right" data-sort-value="'..maxQty..'"|'

			if maxQty > 1 then
				result = result.. '1 - '
			end
			result = result..Shared.formatnum(row[3])

			--Adding price columns
			local itemPrice = 0
			if thisItem == nil then
				result = result..'||data-sort-value="0"|???'
			else
				itemPrice = thisItem.sellsFor ~= nil and thisItem.sellsFor or 0
				if itemPrice == 0 or maxQty == 1 then
					result = result..'||'..Icons.GP(itemPrice)
				else
					result = result..'||'..Icons.GP(itemPrice, itemPrice * maxQty)
				end
			end

			--Getting the drop chance
			local dropChance = (row[2] / totalWt * lootChance)
			if dropChance ~= 100 then
				--Show fraction as long as it isn't going to be 1/1
				result = result..'||style="text-align:right" data-sort-value="'..row[2]..'"'
				result = result..'|'..Shared.fraction(row[2] * lootChance, totalWt * 100)
				result = result..'||'
			else
				result = result..'||colspan="2" data-sort-value="'..row[2]..'"'
			end
			result = result..'style="text-align:right"|'..Shared.round(dropChance, 2, 2)..'%'

			--Adding to the average loot value based on price & dropchance
			lootValue = lootValue + (dropChance * 0.01 * itemPrice * ((1 + maxQty) / 2))
		end
		if multiDrop then
			result = result..'\r\n|-class="sortbottom" \r\n!colspan="3"|Total:'
			if lootChance < 100 then
				result = result..'\r\n|style="text-align:right"|'..Shared.fraction(lootChance, 100)..'||'
			else
				result = result..'\r\n|colspan="2" '
			end
			result = result..'style="text-align:right"|'..Shared.round(lootChance, 2, 2)..'%'
		end
		result = result..'\r\n|}'
		result = result..'\r\nThe loot dropped by the average kill is worth '..Icons.GP(Shared.round(lootValue, 2, 0)).." if sold."
		if avgGp > 0 then
			result = result..'<br/>Including GP, the average kill is worth '..Icons.GP(Shared.round(avgGp + lootValue, 2, 0))..'.'
		end
	end

	--If no other drops, make sure to at least say so.
	if result == '' then result = 'None' end
	return result
end

-- Find drop chance of specified item from specified monster. 
-- Usage: |Monster Name|Item Name
function p.getItemDropChance(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame[1]
	local ItemName = frame.args ~= nil and frame.args[2] or frame[2]
	
	local monster = p.getMonster(MonsterName)
	local item = Items.getItem(ItemName)
	
	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end
	if item == nil then
		return "ERROR: No item with that name found[[Category:Pages with script errors]]"
	end
	
	if not p._isDungeonOnlyMonster(monster) then
		local lootChance = monster.lootChance ~= nil and monster.lootChance or 100

		local totalWt = 0
		--for i, row in pairs(monster.lootTable) do
			--totalWt = totalWt + row[2]
		--end
	
		local dropChance = 0
		local dropWt = 0
		for i, row in Shared.skpairs(monster.lootTable) do
			local thisItem = Items.getItemByID(row[1])
			totalWt = totalWt + row[2]
			if item['id'] == thisItem['id'] then
				dropWt = row[2]
			end
		end
		dropChance = (dropWt / totalWt * lootChance)
		return Shared.round(dropChance, 2, 2)
	end
end	

function p.getChestDrops(frame)
	local ChestName = frame.args ~= nil and frame.args[1] or frame
	local chest = Items.getItem(ChestName)

	if chest == nil then
		return "ERROR: No item named "..ChestName..' found[[Category:Pages with script errors]]'
	end

	local result = ''

	if chest.dropTable == nil then
		return "ERROR: "..ChestName.." does not have a drop table[[Category:Pages with script errors]]"
	else
		local lootChance = 100
		local lootValue = 0

		local multiDrop = Shared.tableCount(chest.dropTable) > 1
		local totalWt = 0
		for i, row in pairs(chest.dropTable) do
			totalWt = totalWt + row[2]
		end
		result = result..'\r\n{|class="wikitable sortable"'
		result = result..'\r\n!Item!!Qty'
		result = result..'!!colspan="2"|Chance!!Price'

		--Sort the loot table by weight in descending order
		for i, row in pairs(chest.dropTable) do
			if chest.dropQty ~= nil then
				table.insert(row, chest.dropQty[i])
			else
				table.insert(row, 1)
			end
		end
		table.sort(chest.dropTable, function(a, b) return a[2] > b[2] end)
		for i, row in Shared.skpairs(chest.dropTable) do
			local thisItem = Items.getItemByID(row[1])
			local qty = row[3]
			result = result..'\r\n|-\r\n|'..Icons.Icon({thisItem.name, type='item'})
			result = result..'||style="text-align:right" data-sort-value="'..qty..'"|'

			if qty > 1 then
				result = result.. '1 - '
			end
			result = result..Shared.formatnum(qty)

			local dropChance = (row[2] / totalWt) * 100
			result = result..'||style="text-align:right" data-sort-value="'..row[2]..'"'
			result = result..'|'..Shared.fraction(row[2], totalWt)

			result = result..'||style="text-align:right"|'..Shared.round(dropChance, 2, 2)..'%'

			result = result..'||style="text-align:left" data-sort-value="'..thisItem.sellsFor..'"'
			if qty > 1 then
				result = result..'|'..Icons.GP(thisItem.sellsFor, thisItem.sellsFor * qty)
			else
				result = result..'|'..Icons.GP(thisItem.sellsFor)
			end
			lootValue = lootValue + (dropChance * 0.01 * thisItem.sellsFor * ((1 + qty)/ 2))
		end
		result = result..'\r\n|}'
		result = result..'\r\nThe average value of the contents of one chest is '..Icons.GP(Shared.round(lootValue, 2, 0))..'.'
	end

	return result
end

function p.getAreaMonsterTable(frame)
	local areaName = frame.args ~= nil and frame.args[1] or frame
	local area = Areas.getArea(areaName)
	if area == nil then
		return "ERROR: Could not find an area named "..areaName..'[[Category:Pages with script errors]]'
	end

	if area.type == 'dungeon' then
		return p.getDungeonMonsterTable(frame)
	end

	local tableTxt = '{| class="wikitable sortable"'
	tableTxt = tableTxt..'\r\n! Name !! Combat Level !! Hitpoints !! Max Hit !! [[Combat Triangle|Combat Style]]'
	for i, monsterID in pairs(area.monsters) do
		local monster = p.getMonsterByID(monsterID)
		tableTxt = tableTxt..'\r\n|-\r\n|'..Icons.Icon({monster.name, type='monster'})
		tableTxt = tableTxt..'||'..p._getMonsterCombatLevel(monster)
		tableTxt = tableTxt..'||'..Shared.formatnum(p.getMonsterHP(monster.name))
		tableTxt = tableTxt..'||'..Shared.formatnum(p.getMonsterMaxHit(monster.name))
		tableTxt = tableTxt..'||'..p.getMonsterStyleIcon({monster.name, nolink=true})
	end
	tableTxt = tableTxt..'\r\n|}'
	return tableTxt
end

function p.getDungeonMonsterTable(frame)
	local areaName = frame.args ~= nil and frame.args[1] or frame
	local area = Areas.getArea(areaName)
	if area == nil then
		return "ERROR: Could not find a dungeon named "..areaName..'[[Category:Pages with script errors]]'
	end

	--For Dungeons, go through and count how many of each monster are in the dungeon first
	local monsterCounts = {}
	for i, monsterID in pairs(area.monsters) do
		if monsterCounts[monsterID] == nil then
			monsterCounts[monsterID] = 1
		else
			monsterCounts[monsterID] = monsterCounts[monsterID] + 1
		end
	end

	local usedMonsters = {}

	-- Declare function for building table rows to avoid repeating code
	local buildRow = function(entityID, monsterCount, specialType)
		local monIcon, monLevel, monHP, monMaxHit, monStyle, monCount
		local monData = {}
		if specialType ~= nil and Shared.contains({'Afflicted', 'SlayerArea'}, specialType) then
			-- Special handling for Into the Mist
			if specialType == 'Afflicted' then
				local iconQ = Icons.Icon({'Into the Mist', notext=true, nolink=true, img='Question'})
				monIcon = Icons.Icon({'Into the Mist', 'Afflicted Monster', nolink=true, img='Question'})
				monLevel, monHP, monMaxHit, monStyle, monCount = iconQ, iconQ, iconQ, iconQ, monsterCount
			elseif specialType == 'SlayerArea' then
				-- entityID corresponds to a slayer area
				local area = Areas.getAreaByID('slayer', entityID)
				monIcon = Icons.Icon({area.name, type='combatArea'}) .. ' Monsters'
				monLevel = {p.getLowHighStat(area.monsters, function(monster) return p._getMonsterCombatLevel(monster) end)}
				monHP = {p.getLowHighStat(area.monsters, function(monster) return p._getMonsterHP(monster) end)}
				local lowMaxHit, highMaxHit = p.getLowHighStat(area.monsters, function(monster) return p._getMonsterMaxHit(monster) end)
				monMaxHit = highMaxHit
				monStyle = Icons.Icon({area.name, area.name, notext=true, nolink=true, img='Question'})
				monCount = monsterCount
			end
		else
			-- entityID corresponds to a monster
			local monster = p.getMonsterByID(entityID)
			monIcon = Icons.Icon({monster.name, type='monster'})
			monLevel = p._getMonsterCombatLevel(monster)
			monHP = p._getMonsterHP(monster)
			monMaxHit = p._getMonsterMaxHit(monster)
			monStyle = p._getMonsterStyleIcon({monster})
			monCount = monsterCount
		end
		local getValSort = function(val)
			if type(val) == 'table' then
				if type(val[1]) == 'number' and type(val[2]) == 'number' then
					return (val[1] + val[2]) / 2
				else
					return (type(val[1]) == 'number' and val[1]) or 0
				end
			else
				return (type(val) == 'number' and val) or 0
			end
		end
		local getValText = function(val)
			if type(val) == 'table' and Shared.tableCount(val) == 2 then
				if type(val[1]) == 'number' and type(val[2]) == 'number' then
					return Shared.formatnum(val[1]) .. ' - ' .. Shared.formatnum(val[2])
				else
					return val[1] .. ' - ' .. val[2]
				end
			elseif type(val) == 'number' then
				return Shared.formatnum(val)
			else
				return val
			end
		end
					
		local resultPart = {}
		table.insert(resultPart, '\r\n|-\r\n| ' .. monIcon)
		table.insert(resultPart, '\r\n|style="text-align:right;" data-sort-value="' .. getValSort(monLevel) .. '"| ' .. getValText(monLevel))
		table.insert(resultPart, '\r\n|style="text-align:right;" data-sort-value="' .. getValSort(monHP) .. '"| ' .. getValText(monHP))
		table.insert(resultPart, '\r\n|style="text-align:right;" data-sort-value="' .. getValSort(monMaxHit) .. '"| ' .. getValText(monMaxHit))
		table.insert(resultPart, '\r\n| ' .. monStyle)
		table.insert(resultPart, '\r\n|style="text-align:right;" data-sort-value="' .. getValSort(monCount) .. '"| ' .. getValText(monCount))
		return table.concat(resultPart)
	end

	local returnPart = {}
	table.insert(returnPart, '{| class="wikitable sortable"')
	table.insert(returnPart, '\r\n! Name !! Combat Level !! Hitpoints !! Max Hit !! [[Combat Triangle|Combat Style]] !! Count')
	-- Special handing for Impending Darkness event
	-- TODO needs to be revised once there is a better understanding of how the event works
	--if area.isEvent ~= nil and area.isEvent then
	--	for i, eventAreaID in ipairs(Areas.eventData.slayerAreas) do
	--		table.insert(returnPart, buildRow(eventAreaID, {5, 8}, 'SlayerArea'))
	--	end
	--  -- Add Bane * 4
	--  table.insert(returnPart, buildRow(152, 4))
	--end
	for i, monsterID in pairs(area.monsters) do
		if not Shared.contains(usedMonsters, monsterID) then
			if monsterID >= 0 then
				table.insert(returnPart, buildRow(monsterID, monsterCounts[monsterID]))
			else
				--Special handling for Into the Mist
				table.insert(returnPart, buildRow(monsterID, monsterCounts[monsterID], 'Afflicted'))
			end
			table.insert(usedMonsters, monsterID)
		end
	end
	table.insert(returnPart, '\r\n|}')
	return table.concat(returnPart)
end

function p.getDungeonTotalHp(frame)
	local areaName = frame.args ~= nil and frame.args[1] or frame
	local area = Areas.getArea(areaName)
	if area == nil then
		return "ERROR: Could not find a dungeon named "..areaName..'[[Category:Pages with script errors]]'
	end
	local totalHP = 0

	for i, monsterID in pairs(area.monsters) do
		if not Shared.contains(usedMonsters, monsterID) then
			local monster = p.getMonsterByID(monsterID)
			totalHP = totalHP + p._getMonsterHP(monster)
		end
	end
	return totalHP
end

function p._getAreaMonsterList(area)
	local monsterList = {}
	for i, monsterID in pairs(area.monsters) do
		local monster = p.getMonsterByID(monsterID)
		table.insert(monsterList, Icons.Icon({monster.name, type='monster'}))
	end
	return table.concat(monsterList, '<br/>')
end

function p._getDungeonMonsterList(area)
	local monsterList = {}
	local lastMonster = nil
	local lastID = -2
	local count = 0
	-- Special handing for Impending Darkness event
	-- TODO needs to be revised once there is a better understanding of how the event works
	--if area.isEvent ~= nil and area.isEvent then
	--	for i, eventAreaID in ipairs(Areas.eventData.slayerAreas) do
	--		local eventArea = Areas.getAreaByID('slayer', eventAreaID)
	--		table.insert(monsterList, '5-8 ' .. Icons.Icon({eventArea.name, type='combatArea'}) .. ' Monsters')
	--	end
	--	table.insert(monsterList, '4 ' .. Icons.Icon({'Bane', type='monster'}))
	--end
	for i, monsterID in Shared.skpairs(area.monsters) do
		if monsterID ~= lastID then
			local monster = nil 
			if monsterID ~= -1 then monster = p.getMonsterByID(monsterID) end
			if lastID ~= -2 then
				if lastID == -1 then
					--Special handling for Afflicted Monsters
					table.insert(monsterList, Icons.Icon({'Affliction', 'Afflicted Monster', img='Question', qty=count}))
				else
					local name = lastMonster.name
					table.insert(monsterList, Icons.Icon({name, type='monster', qty=count}))
				end
			end
			lastMonster = monster
			lastID = monsterID
			count = 1
		else
			count = count + 1
		end
		--Make sure the final monster in the dungeon gets counted
		if i == Shared.tableCount(area.monsters) then
			local name = lastMonster.name
			table.insert(monsterList, Icons.Icon({lastMonster.name, type='monster', qty=count}))
		end
	end
	return table.concat(monsterList, '<br/>')
end

function p.getAreaMonsterList(frame)
	local areaName = frame.args ~= nil and frame.args[1] or frame
	local area = Areas.getArea(areaName)
	if area == nil then
		return "ERROR: Could not find an area named "..areaName..'[[Category:Pages with script errors]]'
	end

	if area.type == 'dungeon' then
		return p._getDungeonMonsterList(area)
	else
		return p._getAreaMonsterList(area)
	end
end

function p.getFoxyTable(frame)
	local result = 'Monster,Min GP,Max GP,Average GP'
	for i, monster in Shared.skpairs(MonsterData.Monsters) do
		if not p._isDungeonOnlyMonster(monster) then
			if monster.dropCoins ~= nil and monster.dropCoins[2] > 1 then
				local avgGp = (monster.dropCoins[1] + monster.dropCoins[2]) / 2
				result = result..'<br/>'..monster.name..','..monster.dropCoins[1]..','..(monster.dropCoins[2])..','..avgGp
			end
		end
	end
	return result
end

function p._getMonsterAverageGP(monster)
	local result = ''
	local totalGP = 0

	local bones = p.getMonsterBones(monster)
	if bones ~= nil then
		totalGP = totalGP + bones.sellsFor * (type(monster.boneQty) == 'number' and monster.boneQty or 1)
	end

	--Likewise, seeing the loot table is tied to the monster appearing outside of dungeons
	if not p._isDungeonOnlyMonster(monster) then
		local lootChance = monster.lootChance ~= nil and monster.lootChance or 100
		local lootValue = 0

		local avgGp = 0

		if monster.dropCoins ~= nil and monster.dropCoins[2] > 1 then
			avgGp = (monster.dropCoins[1] + monster.dropCoins[2]) / 2
		end

		totalGP = totalGP + avgGp

		local multiDrop = Shared.tableCount(monster.lootTable) > 1
		local totalWt = 0
		for i, row in pairs(monster.lootTable) do
			totalWt = totalWt + row[2]
		end

		for i, row in Shared.skpairs(monster.lootTable) do
			local thisItem = Items.getItemByID(row[1])
			local maxQty = row[3]

			local itemPrice = thisItem.sellsFor ~= nil and thisItem.sellsFor or 0

			--Getting the drop chance
			local dropChance = (row[2] / totalWt * lootChance)

			--Adding to the average loot value based on price & dropchance
			lootValue = lootValue + (dropChance * 0.01 * itemPrice * ((1 + maxQty) / 2))
		end

		totalGP = totalGP + lootValue
	end

	return Shared.round(totalGP, 2, 2)
end

function p.getMonsterAverageGP(frame)
	local MonsterName = frame.args ~= nil and frame.args[1] or frame
	local monster = p.getMonster(MonsterName)

	if monster == nil then
		return "ERROR: No monster with that name found[[Category:Pages with script errors]]"
	end

	return p._getMonsterAverageGP(monster)
end

function p.getMonsterEVTable(frame)
	local result = '{| class="wikitable sortable"'
	result = result..'\r\n!Monster!!Combat Level!!Average GP'
	for i, monsterTemp in Shared.skpairs(MonsterData.Monsters) do
		local monster = Shared.clone(monsterTemp)
		monster.id = i - 1
		if not p._isDungeonOnlyMonster(monster) then
			local monsterGP = p._getMonsterAverageGP(monster)
			local combatLevel = p._getMonsterCombatLevel(monster, 'Combat Level')
			result = result..'\r\n|-\r\n|'..Icons.Icon({monster.name, type='monster', noicon=true})..'||'..combatLevel..'||'..monsterGP
		end
	end
	result = result..'\r\n|}'
	return result
end

function p.getSlayerTierMonsterTable(frame)
	-- Input validation
	local tier = frame.args ~= nil and frame.args[1] or frame
	local slayerTier = nil

	if tier == nil then
		return "ERROR: No tier specified[[Category:Pages with script errors]]"
	end

	if tonumber(tier) ~= nil then
		slayerTier = Constants.getSlayerTierByID(tonumber(tier))
	else
		slayerTier = Constants.getSlayerTier(tier)
	end

	if slayerTier == nil then
		return "ERROR: Invalid slayer tier[[Category:Pages with script errors]]"
	end

	-- Obtain required tier details
	local minLevel, maxLevel = slayerTier.minLevel, slayerTier.maxLevel

	-- Build list of monster IDs
	-- Right now hiddenMonsterIDs is empty
	local hiddenMonsterIDs = {}
	local monsterIDs = {}
	for i, monster in Shared.skpairs(MonsterData.Monsters) do
		if monster.canSlayer and not Shared.contains(hiddenMonsterIDs, i - 1) then
			local cmbLevel = p._getMonsterCombatLevel(monster)
			if cmbLevel >= minLevel and (maxLevel == nil or cmbLevel <= maxLevel) then
				table.insert(monsterIDs, i - 1)
			end
		end
	end

	if Shared.tableCount(monsterIDs) == 0 then
		-- Somehow no monsters are in the tier, return nothing
		return ''
	else
		return p._getMonsterTable(monsterIDs, true)
	end
end

function p.getFullMonsterTable(frame)
	local monsterIDs = {}
	for i = 0, Shared.tableCount(MonsterData.Monsters) - 1, 1 do
		table.insert(monsterIDs, i)
	end

	return p._getMonsterTable(monsterIDs, false)
end

function p._getMonsterTable(monsterIDs, excludeDungeons)
	--Making a single function for getting a table of monsters given a list of IDs.
	local hideDungeons = excludeDungeons ~= nil and excludeDungeons or false
	local tableParts = {}
	table.insert(tableParts, '{| class="wikitable sortable stickyHeader"')
	-- First header row
	table.insert(tableParts, '\r\n|- class="headerRow-0"\r\n! colspan="5" | !! colspan="4" |Offensive Stats !! colspan="3" |Evasion Rating !! colspan="4" |')
	-- Second header row
	table.insert(tableParts, '\r\n|- class="headerRow-1"\r\n!Monster !!Name !!ID !!Combat Level ')
	table.insert(tableParts, '!!style="padding:0 1em 0 0"|' .. Icons.Icon({'Hitpoints', type='skill'}))
	table.insert(tableParts, '!!Attack Speed (s) !!colspan="2"|Max Hit !!Accuracy ')
	table.insert(tableParts, '!!style="padding:0 1em 0 0"|' .. Icons.Icon({'Defence', type='skill', notext=true}))
	table.insert(tableParts, '!!style="padding:0 1em 0 0"|' .. Icons.Icon({'Ranged', type='skill', notext=true}))
	table.insert(tableParts, '!!style="padding:0 1em 0 0"|' .. Icons.Icon({'Magic', type='skill', notext=true}))
	table.insert(tableParts, '!!' .. Icons.Icon({'Coins', notext=true, nolink=true}) .. ' Coins !!Bones !!Locations')

	-- Generate row per monster
	for i, monsterID in Shared.skpairs(monsterIDs) do
		local monster = p.getMonsterByID(monsterID)
		local cmbLevel = p._getMonsterCombatLevel(monster)
		local atkSpeed = p._getMonsterAttackSpeed(monster)
		local maxHit = p._getMonsterMaxHit(monster)
		local accR = p._getMonsterAR(monster)
		local evaR = {p._getMonsterER(monster, "Melee"), p._getMonsterER(monster, "Ranged"), p._getMonsterER(monster, "Magic")}

		local gpRange = {0, 0}
		if monster.dropCoins ~= nil and monster.dropCoins[2] > 1 then
			gpRange = {monster.dropCoins[1], monster.dropCoins[2]}
		end
		local gpTxt = nil
		if gpRange[1] >= gpRange[2] then
			gpTxt = Shared.formatnum(gpRange[1])
		else
			gpTxt = Shared.formatnum(gpRange[1]) .. ' - ' .. Shared.formatnum(gpRange[2])
		end
		local bones = p.getMonsterBones(monster)
		local boneTxt = (bones ~= nil and Icons.Icon({bones.name, type='item', notext=true})) or 'None'

		table.insert(tableParts, '\r\n|-\r\n|style="text-align: center;" |' .. Icons.Icon({monster.name, type='monster', size=50, notext=true}))
		table.insert(tableParts, '\r\n|style="text-align:left" |' .. Icons.Icon({monster.name, type='monster', noicon=true}))
		table.insert(tableParts, '\r\n|style="text-align:right" |' .. monsterID)
		table.insert(tableParts, '\r\n|style="text-align:right" data-sort-value="' .. cmbLevel .. '" |' .. Shared.formatnum(cmbLevel))
		table.insert(tableParts, '\r\n|style="text-align:right" data-sort-value="' .. p._getMonsterHP(monster) .. '" |' .. Shared.formatnum(p._getMonsterHP(monster)))
		table.insert(tableParts, '\r\n|style="text-align:right" data-sort-value="' .. atkSpeed .. '" |' .. Shared.round(atkSpeed, 1, 1))
		table.insert(tableParts, '\r\n|style="text-align:center;border-right:hidden" |' .. p._getMonsterStyleIcon({monster, notext=true}))
		table.insert(tableParts, '\r\n|style="text-align:right" data-sort-value="' .. maxHit .. '" |' .. Shared.formatnum(maxHit))
		table.insert(tableParts, '\r\n|style="text-align:right" data-sort-value="' .. accR .. '" |' .. Shared.formatnum(accR))
		table.insert(tableParts, '\r\n|style="text-align:right" data-sort-value="' .. evaR[1] .. '" |' .. Shared.formatnum(evaR[1]))
		table.insert(tableParts, '\r\n|style="text-align:right" data-sort-value="' .. evaR[2] .. '" |' .. Shared.formatnum(evaR[2]))
		table.insert(tableParts, '\r\n|style="text-align:right" data-sort-value="' .. evaR[3] .. '" |' .. Shared.formatnum(evaR[3]))
		table.insert(tableParts, '\r\n|style="text-align:right" data-sort-value="' .. (gpRange[1] + gpRange[2]) / 2 .. '" |' .. gpTxt)
		table.insert(tableParts, '\r\n|style="text-align:center" |' .. boneTxt)
		table.insert(tableParts, '\r\n|style="text-align:right;width:190px" |' .. p._getMonsterAreas(monster, hideDungeons))
	end

	table.insert(tableParts, '\r\n|}')
	return table.concat(tableParts)
end

function p.getSpecialAttackTable(frame)
	local spAttTable = {}

	for i, monster in ipairs(MonsterData.Monsters) do
		if monster.specialAttacks ~= nil and Shared.tableCount(monster.specialAttacks) > 0 then
			local overrideChance = (monster.overrideSpecialChances ~= nil and Shared.tableCount(monster.overrideSpecialChances) > 0)
			for j, spAtt in ipairs(monster.specialAttacks) do
				local attChance = (overrideChance and monster.overrideSpecialChances[j] or spAtt.defaultChance)
				if spAttTable[spAtt.id] == nil then
					spAttTable[spAtt.id] = { ['defn'] = spAtt, ['icons'] = {} }
				end
				if spAttTable[spAtt.id]['icons'][attChance] == nil then
					spAttTable[spAtt.id]['icons'][attChance] = {}
				end
				table.insert(spAttTable[spAtt.id]['icons'][attChance], Icons.Icon({ monster.name, type = 'monster' }))
			end
		end
	end

	local resultPart = {}
	table.insert(resultPart, '{|class="wikitable sortable stickyHeader"')
	table.insert(resultPart, '\r\n|- class="headerRow-0"')
	table.insert(resultPart, '\r\n!Name!!style="min-width:225px"|Monsters!!Chance!!Effect')

	for i, spAttData in Shared.skpairs(spAttTable) do
		local spAtt = spAttData.defn
		local firstRow = true
		local rowsSpanned = Shared.tableCount(spAttData.icons)
		local rowSuffix = ''
		if rowsSpanned > 1 then
			rowSuffix = '|rowspan="' .. rowsSpanned .. '"'
		end
		for chance, iconList in Shared.skpairs(spAttData.icons) do
			table.insert(resultPart, '\r\n|-')
			if firstRow then
				table.insert(resultPart, '\r\n' .. rowSuffix .. '| ' .. spAtt.name)
			end
			table.insert(resultPart, '\r\n|data-sort-value="' .. spAtt.name .. '"| ' .. table.concat(iconList, '<br/>'))
			table.insert(resultPart, '\r\n|data-sort-value="' .. chance .. '"| ' .. Shared.round(chance, 2, 0) .. '%')
			if firstRow then
				table.insert(resultPart, '\r\n' .. rowSuffix .. '| ' .. spAtt.description)
				firstRow = false
			end
		end
	end
	table.insert(resultPart, '\r\n|}')

	return table.concat(resultPart)
end

return p