Module:Monsters: Difference between revisions

From Melvor Idle
(Added an additional check to the activeBuffs check to fix an issue with specifically Aeris's Cyclone attack.)
(Added check to allow for showing monster normal attacks if it sometimes does it as a fallback.)
Line 413: Line 413:
     typeText = 'Magic'
     typeText = 'Magic'
   end
   end
  local buffAttacks = {}
  local hasActiveBuffSpec = false


   local normalAttackChance = 100
   local normalAttackChance = 100
Line 427: Line 430:


       result = result..'\r\n* '..attChance..'% '..iconText..' '..specAttack.name..'\r\n** '..specAttack.description
       result = result..'\r\n* '..attChance..'% '..iconText..' '..specAttack.name..'\r\n** '..specAttack.description
      if specAttack.activeBuffs and specAttack.activeBuffTurns ~= nil and specAttack.activeBuffTurns > 0 then
        table.insert(buffAttacks, specAttack.name)
        hasActiveBuffSpec = true
      end
     end
     end
   end
   end
   if normalAttackChance == 100 then
   if normalAttackChance == 100 then
     result = iconText..'1 - '..p.getMonsterBaseMaxHit(frame)..' '..typeText..' Damage'
     result = iconText..'1 - '..p._getMonsterBaseMaxHit(monster)..' '..typeText..' Damage'
   elseif normalAttackChance > 0 then
   elseif normalAttackChance > 0 then
     result = '* '..normalAttackChance..'% '..iconText..'1-'..p.getMonsterBaseMaxHit(frame)..' '..typeText..' Damage'..result
     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 = '* 1 - '..p._getMonsterBaseMaxHit(monster)..' '..typeText..' Damage (Instead of repeating '..table.concat(buffAttacks, ' or ')..' while the buff is already active)'..result
   end
   end
   return result
   return result
end
end

Revision as of 16:13, 2 August 2021

Data is pulled from Module:GameData/data

Tables are in Module:Monsters/Tables


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.getSpecialAttack(name)
  local result = nil

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

function p.getSpecialAttackByID(ID)
  return MonsterData.SpecialAttacks[ID + 1]
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(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

  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'})
  end

  return 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  Constants.getCombatStyleName(monster.attackType) == 'Melee' then
    iconText = Icons.Icon({'Melee', notext=notext, nolink=nolink})
  elseif Constants.getCombatStyleName(monster.attackType) == 'Ranged' then
    iconText = Icons.Icon({'Ranged', type='skill', notext=notext, nolink=nolink})
  elseif Constants.getCombatStyleName(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 monster.hitpoints * 10
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._getMonsterAttackSpeed(monster)
  return 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 * (monster.defenceLevel + monster.hitpoints)
  local melee = 0.325 * (monster.attackLevel + monster.strengthLevel)
  local range = 0.325 * (1.5 * monster.rangedLevel)
  local magic = 0.325 * (1.5 * monster.magicLevel)
  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 effAttLvl = 0
  local attBonus = 0
  if Constants.getCombatStyleName(monster.attackType) == 'Melee' then
    effAttLvl = monster.attackLevel + 9
    attBonus = monster.attackBonus + 64
  elseif Constants.getCombatStyleName(monster.attackType) == 'Ranged' then
    effAttLvl = monster.rangedLevel + 9
    attBonus = monster.attackBonusRanged + 64
  elseif Constants.getCombatStyleName(monster.attackType) == 'Magic' then
    effAttLvl = monster.magicLevel + 9
    attBonus = monster.attackBonusMagic + 64
  else
    return "ERROR: This monster has an invalid attack type somehow[[Category:Pages with script errors]]"
  end

  return effAttLvl * attBonus
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(frame)
  local args = frame.args ~= nil and frame.args or frame
  local monster = args[1]
  local style = args[2]

  local effDefLvl = 0
  local defBonus = 0
  if style == "Melee" then
    effDefLvl = monster.defenceLevel + 9
    defBonus = monster.defenceBonus + 64
  elseif style == "Ranged" then
    effDefLvl = monster.defenceLevel + 9
    defBonus = monster.defenceBonusRanged + 64
  elseif style == "Magic" then
    effDefLvl = math.floor(monster.magicLevel * 0.7) + math.floor(monster.defenceLevel * 0.3) + 9
    defBonus = monster.defenceBonusMagic + 64
  else
    return "ERROR: Must choose Melee, Ranged, or Magic[[Category:Pages with script errors]]"
  end
  return effDefLvl * defBonus
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._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.hasSpecialAttack then
    local canStun = false
    local canSleep = false
    for i, specID in pairs(monster.specialAttackID) do
      local specAttack = p.getSpecialAttackByID(specID)
      if monster.overrideSpecialChances ~= nil then
        normalChance = normalChance - monster.overrideSpecialChances[i]
      else
        normalChance = normalChance - specAttack.chance
      end
      local thisMax = 0
      if specAttack.setDamage ~= nil then
        thisMax = specAttack.setDamage * 10
      else
        thisMax = normalMaxHit
      end
      if specAttack.canStun ~= nil and specAttack.canStun then canStun = true end
      if specAttack.canSleep ~= nil and specAttack.canSleep then canSleep = true end

      if thisMax > specialMaxHit then specialMaxHit = thisMax end
      if specAttack.activeBuffs and specAttack.activeBuffTurns ~= nil and specAttack.activeBuffTurns > 0 then 
        hasActiveBuffSpec = true 
      end
    end

    if canStun and doStuns then damageMultiplier = damageMultiplier * 1.3 end
    if canSleep and doStuns then damageMultiplier = damageMultiplier * 1.2 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)
  local effStrLvl = 0
  local strBonus = 0
  if Constants.getCombatStyleName(monster.attackType) == 'Melee' then
    effStrLvl = monster.strengthLevel + 9
    strBonus = monster.strengthBonus
  elseif Constants.getCombatStyleName(monster.attackType) == 'Ranged' then
    effStrLvl = monster.rangedLevel + 9
    strBonus = monster.strengthBonusRanged
  elseif Constants.getCombatStyleName(monster.attackType) == 'Magic' then
    local mSpell = nil
    if monster.selectedSpell ~= nil then mSpell = Magic.getSpellByID('Spells', monster.selectedSpell) end
    if mSpell == nil then
      return math.floor(10 * (monster.setMaxHit + (monster.setMaxHit * monster.damageBonusMagic / 100)))
    else
      return math.floor(10 * (mSpell.maxHit + (mSpell.maxHit * monster.damageBonusMagic / 100)))
    end
  else
    return "ERROR: This monster has an invalid attack type somehow[[Category:Pages with script errors]]"
  end

  --Should only get here for Melee/Ranged, which use functionally the same damage formula
  return math.floor(10 * (1.3 + (effStrLvl/10) + (strBonus / 80) + ((effStrLvl * strBonus) / 640)))
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  Constants.getCombatStyleName(monster.attackType) == 'Melee' then
    iconText = Icons.Icon({'Melee', notext=true})
    typeText = 'Melee'
  elseif Constants.getCombatStyleName(monster.attackType) == 'Ranged' then
    iconText = Icons.Icon({'Ranged', type='skill', notext=true})
    typeText = 'Ranged'
  elseif Constants.getCombatStyleName(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.hasSpecialAttack then
    for i, specID in pairs(monster.specialAttackID) do
      local specAttack = p.getSpecialAttackByID(specID)
      local attChance = 0
      if monster.overrideSpecialChances ~= nil then
        attChance = monster.overrideSpecialChances[i]
      else
        attChance = specAttack.chance
      end
      normalAttackChance = normalAttackChance - attChance

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

      if specAttack.activeBuffs and specAttack.activeBuffTurns ~= nil and specAttack.activeBuffTurns > 0 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 = '* 1 - '..p._getMonsterBaseMaxHit(monster)..' '..typeText..' Damage (Instead of repeating '..table.concat(buffAttacks, ' or ')..' while the buff 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)
      local passiveChance = 0
      if passive.chance ~= nil then
        passiveChance = passive.chance
      end

      result = result .. '\r\n* ' .. Shared.round(passiveChance, 2, 0) .. '% ' .. 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 Constants.getCombatStyleName(monster.attackType) == 'Melee' then
    result = result..'[[Category:Melee Monsters]]'
  elseif Constants.getCombatStyleName(monster.attackType) == 'Ranged' then
    result = result..'[[Category:Ranged Monsters]]'
  elseif Constants.getCombatStyleName(monster.attackType) == 'Magic' then
    result = result..'[[Category:Magic Monsters]]'
  end

  if monster.hasSpecialAttack 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 not p._isDungeonOnlyMonster(monster) then
    SlayerTier = Constants.getSlayerTierNameByLevel(p._getMonsterCombatLevel(monster))
  end

  result = result.."\r\n|-\r\n|'''"..Icons.Icon({'Slayer', type='skill'}).." [[Slayer#Slayer Tier Monsters|Tier]]:''' "..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 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
      local monster = p.getMonsterByID(monsterID)
      local name = monster.name
      if monsterID == 51 then name = 'Spider2' end
      if monsterID ~= 1 then
        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 = -1
  local count = 0
  for i, monsterID in Shared.skpairs(area.monsters) do
    if monsterID ~= lastID then
      local monster = p.getMonsterByID(monsterID)
      if lastMonster ~= nil 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 = monster.id
      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 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
  if maxLevel < 0 then
    maxLevel = nil
  end

  -- 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 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="' .. monster.hitpoints .. '" |' .. 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