Module:Monsters

From Melvor Idle
Revision as of 06:43, 11 September 2021 by ByteFoolish (talk | contribs) (Turn MonsterBox SlayerTier into link)
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

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})
  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.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'))
  if melee > range and melee > magic then
    return math.floor(base + melee)
  elseif range > magic then
    return math.floor(base + range)
  else
    return math.floor(base + magic)
  end
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')
  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

function p._isDungeonOnlyMonster(monster)
  local areaList = Areas.getMonsterAreas(monster.id)
  local dunCount = 0
  local nonDunCount = 0

  for i, area in Shared.skpairs(areaList) do
    if area.type == 'dungeon' then
      dunCount = dunCount + 1
    else
      nonDunCount = nonDunCount + 1
    end
  end
  return dunCount > 0 and nonDunCount == 0
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 result = ''
  local hideDungeons = excludeDungeons ~= nil and excludeDungeons or false
  local areaList = Areas.getMonsterAreas(monster.id)
  for i, area in pairs(areaList) do
    if area.type ~= 'dungeon' or not hideDungeons then
      if i > 1 then result = result..'<br/>' end
      result = result..Icons.Icon({area.name, type = area.type})
    end
  end
  return result
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 dmgRoll == "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
      end
    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 = false
    local canSleep = 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 p.canSpecAttackApplyEffect(specAttack, 'Stun') then canStun = true end
      if 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
    mw.log(canStun)

    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
  if monster.attackType == 'melee' then
    local baseLevel = p._getMonsterLevel(monster, 'Strength')
    local bonus = p.getEquipmentStat(monster, 'meleeStrengthBonus')
    result = p.calculateStandardMaxHit(baseLevel, bonus)
  elseif monster.attackType == 'ranged' then
    local baseLevel = p._getMonsterLevel(monster, 'Ranged')
    local 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
       
    local bonus = p.getEquipmentStat(monster, 'magicDamageBonus')
    local baseLevel = p._getMonsterLevel(monster, 'Magic')

    result = math.floor(10 * mSpell.maxHit * (1 + bonus / 100) * (1 + (baseLevel + 1) / 200))
  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 = ''
  local typeText = ''
  if  monster.attackType == 'melee' then
    iconText = Icons.Icon({'Melee', notext=true})
    typeText = 'Melee'
  elseif monster.attackType == 'ranged' then
    iconText = Icons.Icon({'Ranged', type='skill', notext=true})
    typeText = 'Ranged'
  elseif monster.attackType == 'magic' then
    iconText = Icons.Icon({'Magic', type='skill', notext=true})
    typeText = 'Magic'
  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.hasPassive 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'})
  result = result.." [[Slayer#Slayer Tier Monsters|Tier]]:''' ".."[[Slayer#"..SlayerTier.."|"..SlayerTier.."]]"

  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 = ''

  if monster.bones ~= nil and monster.bones >= 0 then
    local bones = Items.getItemByID(monster.bones)
    --Show the bones only if either the monster shows up outside of dungeons _or_ the monster drops shards
    if not p._isDungeonOnlyMonster(monster) or Shared.contains(bones.name, 'Shard') then
      result = result.."'''Always Drops:'''"
      result = result..'\r\n{|class="wikitable"'
      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
  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] - 1) / 2
      local gpTxt = Icons.GP(monster.dropCoins[1], monster.dropCoins[2] - 1)
      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"'
    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]
      result = result..'\r\n|-\r\n|'..Icons.Icon({thisItem.name, type='item'})
      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 = 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

      --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"|'..lootChance..'.00%'
    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

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 = {}

  local tableTxt = '{| class="wikitable sortable"'
  tableTxt = tableTxt..'\r\n! Name !! Combat Level !! Hitpoints !! Max Hit !! [[Combat Triangle|Combat Style]] !! Count'
  for i, monsterID in pairs(area.monsters) do
    if not Shared.contains(usedMonsters, monsterID) then
      if monsterID >= 0 then
        local monster = p.getMonsterByID(monsterID)
        local name = monster.name
        if monsterID == 51 then name = 'Spider2' end
        tableTxt = tableTxt..'\r\n|-\r\n|'..Icons.Icon({name, type='monster'})
        tableTxt = tableTxt..'||'..p._getMonsterCombatLevel(monster)
        tableTxt = tableTxt..'||'..Shared.formatnum(p.getMonsterHP(name))
        tableTxt = tableTxt..'||'..Shared.formatnum(p.getMonsterMaxHit(name))
        tableTxt = tableTxt..'||'..p.getMonsterStyleIcon({name, nolink='true'})
        tableTxt = tableTxt..'||'..monsterCounts[monsterID]
      else
        --Special handling for Into the Mist
        tableTxt = tableTxt..'\r\n|-\r\n|'..Icons.Icon({'Into the Mist', 'Afflicted Monster', nolink=true, img='Question'})
        tableTxt = tableTxt..'||data-sort-value="0"|'..Icons.Icon({'Into the Mist', notext=true, nolink=true, img='Question'})
        tableTxt = tableTxt..'||data-sort-value="0"|'..Icons.Icon({'Into the Mist', notext=true, nolink=true, img='Question'})
        tableTxt = tableTxt..'||data-sort-value="0"|'..Icons.Icon({'Into the Mist', notext=true, nolink=true, img='Question'})
        tableTxt = tableTxt..'||data-sort-value="0"|'..Icons.Icon({'Into the Mist', notext=true, nolink=true, img='Question'})
        tableTxt = tableTxt..'||'..monsterCounts[monsterID]
      end
      table.insert(usedMonsters, monsterID)
    end
  end
  tableTxt = tableTxt..'\r\n|}'
  return tableTxt
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
  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
          if lastMonster.id == 51 then name = 'Spider2' end
          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] - 1) / 2
       result = result..'<br/>'..monster.name..','..monster.dropCoins[1]..','..(monster.dropCoins[2]-1)..','..avgGp
      end
    end
  end
  return result
end

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

  if monster.bones ~= nil and monster.bones >= 0 then
    local bones = Items.getItemByID(monster.bones)
    --Show the bones only if either the monster shows up outside of dungeons _or_ the monster drops shards
    if not p._isDungeonOnlyMonster(monster) or Shared.contains(bones.name, 'Shard') then
      totalGP = totalGP + bones.sellsFor
    end
  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] - 1) / 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|[['..monster.name..']]||'..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 Type !!Attack Speed (s) !!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, '!!Drop Chance !!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 lootChance = monster.lootChance ~= nil and monster.lootChance or 100
    local gpRange = {0, 0}
    if monster.dropCoins ~= nil and monster.dropCoins[2] > 1 then
      gpRange = {monster.dropCoins[1], monster.dropCoins[2] - 1}
    end
    local gpTxt = nil
    if gpRange[1] >= gpRange[2] then
      gpTxt = Icons.GP(gpRange[1])
    else
      gpTxt = Icons.GP(gpRange[1], gpRange[2])
    end
    local boneTxt = 'None'
    if monster.bones ~= nil and monster.bones >= 0 then
      local bones = Items.getItemByID(monster.bones)
      boneTxt = Icons.Icon({bones.name, type='item', notext=true})
    end

    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" |[[' .. monster.name .. ']]')
    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;white-space:nowrap" |' .. p._getMonsterStyleIcon({monster, nolink='true'}))
    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: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="' .. lootChance .. '" |' .. lootChance .. '%')
    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;white-space:nowrap" |' .. p._getMonsterAreas(monster, hideDungeons))
  end

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

return p