Module:Items/ComparisonTables: Difference between revisions

From Melvor Idle
(Implement equipment category table generation for specific styles (Melee, Ranged, Magic, Other))
(Amend to level sorting, ensures that abyssal level requirements are always sorted after normal levels)
Line 302: Line 302:
      
      
     if Shared.tableIsEmpty(abyssalSkills) == false then
     if Shared.tableIsEmpty(abyssalSkills) == false then
     highestLvReq = highestLvReq + 99
     highestLvReq = highestLvReq + 200
     end
     end



Revision as of 18:45, 21 July 2024

Documentation for this module may be created at Module:Items/ComparisonTables/doc

local p = {}

local Shared = require('Module:Shared')
local GameData = require('Module:GameData')
local Common = require('Module:Common')
local Modifiers = require('Module:Modifiers')
local Icons = require('Module:Icons')
local Items = require('Module:Items')
local Num = require('Module:Number')
local FL = require('Module:FunList')

local weaponTypes = {'Magic Staff', 'Magic Wand', 'Ranged Weapon', 'Weapon'}

local styleOverrides = {
	Melee = {'Slayer Helmet (Basic)', 'Slayer Platebody (Basic)', 'Paladin Gloves', 'Desert Wrappings', 'Almighty Lute', 'Candy Cane', "Bob's Rake", "Knight's Defender", "Ward of Flame Platebody"},
	Ranged = {'Slayer Cowl (Basic)', 'Slayer Leather Body (Basic)', 'Ice Arrows'},
	Magic = {'Slayer Wizard Hat (Basic)', 'Slayer Wizard Robes (Basic)', 'Enchanted Shield', 'Elementalist Gloves', 'Frostspark Boots', 'Freezing Touch Body', 'Lightning Boots'},
	None = {},
	NotMelee = {'Frostspark Boots', 'Freezing Touch Body', 'Lightning Boots'},
	NotRanged = {},
	NotMagic = {'Torrential Blast Crossbow', 'Spectral Ice Sword', 'Lightning Strike 1H Sword', 'FrostSpark 1H Sword'}
}

local slotOverrides = {
	['2hWeapons'] = 'melvorD:2hWeapons',
	['Enhancement'] = 'melvorD:Enhancement'
}

local itemStyleOverrides = {
	['Melee'] = {
		'melvorF:Slayer_Helmet_Basic',
		'melvorF:Slayer_Platebody_Basic',
		'melvorF:Paladin_Gloves',
		'melvorF:Desert_Wrappings',
		'melvorF:Knights_Defender',
	},
	['Ranged'] = {
		'melvorF:Slayer_Cowl_Basic',
		'melvorF:Slayer_Leather_Body_Basic',
		'melvorD:Cape_Of_Prat',
		'melvorD:Ice_Arrows',
	},
	['Magic'] = {
		'melvorF:Slayer_Wizard_Hat_Basic',
		'melvorF:Slayer_Wizard_Robes_Basic',
		'melvorF:Skull_Cape',
		'melvorD:Enchanted_Shield',
		'melvorF:Elementalist_Gloves',
		'melvorTotH:Freezing_Touch_Body',
		'melvorTotH:Lightning_Boots',
	},
	['Other'] = {

	}
}

-- Categorise equipment by:
-- - Equipment slot ID
-- - Style (Melee, Ranged, Magic, Other)
p.SlotEquipment = {}

function p.populateSlotEquipment()
	-- Populate from item data
	local hiddenItems = {}
	for _, itemID in ipairs(Items.HiddenItems) do
		hiddenItems[itemID] = true
	end

	-- Transform style overrides into a form where items are indexed by ID
	local styleOverrides = {}
	for overType, itemIDs in pairs(itemStyleOverrides) do
		for _, itemID in ipairs(itemIDs) do
			styleOverrides[itemID] = overType
		end
	end

	for _, item in ipairs(GameData.rawData.items) do
		if (
			-- Item is not hidden (includes debug items)
			hiddenItems[item.id] == nil
			-- Item isn't exclusive to Golbin Raid minigame
			and (item.golbinRaidExclusive == nil or not item.golbinRaidExclusive)
			-- Item can be equipped to any slot
			and item.validSlots ~= nil
		) then
			-- Item is equippment to be included within equipment tables
			local equipEntry = {
				['item'] = item,
				['slots'] = {},
				['isWeapon'] = nil,
				['style'] = nil
			}
			-- Determine which slots the equipment can be equipped to
			local occupiesSlots = item.occupiesSlots or {}
			for _, slotLocalID in ipairs(item.validSlots) do
				local newSlotID = slotLocalID
				-- Separate two-handed weapons from other weapons
				if slotLocalID == 'Weapon' and Items._getItemStat(item, 'isTwoHanded') then
					newSlotID = '2hWeapons'
				-- Classify javelins and throwing knives as weapons
				elseif slotLocalID == 'Quiver' and Shared.contains({'Javelins', 'ThrowingKnives'}, item.ammoType) then
					newSlotID = 'Weapon'
				-- Combine all enhancements
				elseif Shared.contains({'Enhancement1', 'Enhancement2', 'Enhancment3'}, slotLocalID) then
					newSlotID = 'Enhancement'
				end
				equipEntry.slots[newSlotID] = true
			end
			equipEntry.isWeapon = equipEntry.slots['Weapon'] or equipEntry.slots['2hWeapons'] or Shared.contains(occupiesSlots, 'Weapon')

			-- Determine the style of the item (Melee, Ranged, Magic, Other)
			local function hasSkillReq(reqs, localSkillID)
				if reqs ~= nil then
					local skillID = Shared.getNamespacedID('melvorD', localSkillID)
					for levelType, typeReqs in pairs(reqs) do
						if typeReqs[skillID] ~= nil then
							return true
						end
					end
				end
				return false
			end
			local levelReqs = Items._getItemLevelReqs(item)
			-- Apply any overrides first
			if styleOverrides[item.id] ~= nil then
				equipEntry.style = styleOverrides[item.id]
			-- Weapon styles can be checked using the attackType property
			elseif equipEntry.isWeapon and Shared.contains({'melee', 'ranged', 'magic'}, item.attackType) then
				equipEntry.style = Shared.titleCase(item.attackType)
			-- Magic
			elseif hasSkillReq(levelReqs, 'Magic') then
				equipEntry.style = 'Magic'
			-- Ranged
			elseif (
				hasSkillReq(levelReqs, 'Ranged')
				or equipEntry.slots.Quiver ~= nil
				or item.ammoType ~= nil
		 	) then
				equipEntry.style = 'Ranged'
			-- Melee
			elseif (
				hasSkillReq(levelReqs, 'Attack')
				or hasSkillReq(levelReqs, 'Defence')
			) then
				equipEntry.style = 'Melee'
			-- Other, default style if unmatched
			else
				equipEntry.style = 'Other'
			end
			-- Finally, add the entry into the slotEquipment table
			table.insert(p.SlotEquipment, equipEntry)
		end
	end
end

p.populateSlotEquipment()

local function getEquipItemList(filter)
	local equip = GameData.getEntities(p.SlotEquipment, function(x) return filter(x) end)
	local items = {}
	for _, entry in ipairs(equip) do
		table.insert(items, entry.item)
	end
	return items
end

local function getSlotID(slot)
	local slotID = Shared.getNamespacedID('melvorD', slot)
	local slotData = GameData.getEntityByID('equipmentSlots', slotID)

	if slotData == nil then
		if slotOverrides[slot] ~= nil then
			return slotOverrides[slot]
		end

		-- slotID invalid, check if user provided a slot name
		slotData = GameData.getEntityByProperty('equipmentSlots', 'emptyName', slot)
		if slotData == nil then
			return nil
		end
		slotID = slotData.id
	end
	return slotID
end

local function getItemDesc(item)
	if item.customDescription ~= nil then
		return item.customDescription
	elseif item.modifiers ~= nil then
		return Modifiers.getModifiersText(item.modifiers, false, false)
	else
		return ''
	end
end

local function getItems(slotID, style)
	local _, slotLocalID = Shared.getLocalID(slotID)

	return getEquipItemList(
		function(entry)
			return (
				entry.slots[slotLocalID] ~= nil
				and (style == nil or style == '' or style == entry.style)
			)
		end
	)
end

--== Helper Functions for getCategoryTable ==--
local function createStatCell(row, statVal)
	local cell = row:tag('td')
	if statVal > 0 then
		cell:addClass('table-positive')
	elseif statVal < 0 then
		cell:addClass('table-negative')
	end
	if math.abs(statVal) >= 1000 then
		cell:attr('data-sort-value', statVal)
	end
	cell:css('text-align', 'right')
	return cell
end

local function addStatCell(row, item, stat)
	local statVal = 0
	if item.equipmentStats ~= nil then
		statVal = item.equipmentStats[stat] or 0
	end
	
	return createStatCell(row, statVal)
		:wikitext(Num.formatnum(statVal))
end

local function addDRCell(row, item)
	local dr = 0
	local icon = nil
	
	-- Grab damage reduction figure
	if item.equipmentStats ~= nil then
		if item.equipmentStats.damageReduction then
			dr, icon = item.equipmentStats.damageReduction, 'Damage Reduction'
		elseif item.equipmentStats.resistanceAbyssal then
			dr, icon = item.equipmentStats.resistanceAbyssal, 'Abyssal Resistance'
		elseif item.equipmentStats.resistanceEternal then
			dr, icon = item.equipmentStats.resistanceEternal, 'Eternal Resistance'
		end
	end
	
	local cell = createStatCell(row, dr)
	
	-- Add DR icons, if there's any value
	if dr ~= 0 then
		cell:wikitext(Icons.Icon({icon, size=15, notext='true'}) .. ' ')
	end
	
	-- Add DR value
	cell:wikitext(dr .. '%')
	return cell
end

local function getRequirements(item)
	if item.equipRequirements == nil then 
		return nil
	end
	
	local function getSkillName(skillID)
		local _, localSkillID = GameData.getLocalID(skillID)
		return localSkillID
	end
	
	local iconFuncs = {
		['AbyssalLevel'] = function(x) 
			return Icons._SkillRealmIcon(getSkillName(x.skillID), 'melvorItA:Abyssal') .. ' ' .. x.level 
		end,
		['SkillLevel'] = function(x) 
			return Icons._SkillRealmIcon(getSkillName(x.skillID)) .. ' ' .. x.level 
		end,
	}
	
	local reqs = {}
	local abyssalSkills = {}
	local highestLvReq = 0
	
	-- Filter out all Abyssal Levels
    for _, req in ipairs(item.equipRequirements) do
        if req.type == 'AbyssalLevel' then abyssalSkills[req.skillID] = true end
    end
	
	-- If the req is a SkillLevel, but the skillID is already an AbyssalLevel, skip the entry
	-- These are likely 99 Level requirements in addition to the AbyssalLevel requirement.
    for _, req in ipairs(item.equipRequirements) do
		if not (req.type == 'SkillLevel' and abyssalSkills[req.skillID] == true) then
			-- Add requirement via factory function.
			local func = iconFuncs[req.type]
			if func then table.insert(reqs, func(req)) end
			
			-- Track highest level for data sorting.
			local lv = req.level or 0
			if lv > highestLvReq then highestLvReq = lv end
		end
    end
    
    if Shared.tableIsEmpty(abyssalSkills) == false then
    	highestLvReq = highestLvReq + 200
    end

	return {
		['datasortvalue'] = highestLvReq,
		['requirements'] = table.concat(reqs, '<br>')
	}
end

function p.getEquipmentTable(itemList, slot, style)
	local iconSize = 20
	local fl = FL.new(itemList)
	
	-- If slot is nil, find out if we have any weapons.
	if slot == nil then
		if fl:any(function(x) return Shared.contains(x.validSlots, 'Weapon') end) then
			slot = 'Weapon'
		end
	end
	
	-- Sort itemList by name
	itemList = fl:sortBy(function(x) return x.name end)
				 :sort()
				 :toTable()
				 
	local isWeapon = (slot == 'Weapon' or slot == '2hWeapons')
	local itemColspan = 3
	if isWeapon == true then itemColspan = 4 end
	
	local html = mw.html.create('table')
		:addClass('wikitable sortable stickyHeader')
		:addClass('col-1-center col-3-center')

	local header0 = html:tag('tr'):addClass('headerRow-0')
	header0:tag('th'):attr('colspan', itemColspan)
	header0:tag('th'):attr('colspan', 5)
					 :wikitext("Attack Bonus")
	header0:tag('th'):attr('colspan', 3)
					 :wikitext("Strength Bonus")
	header0:tag('th'):attr('colspan', 3)
					 :wikitext("Defence Bonus")

	header0:tag('th'):wikitext("DR/AR")
	header0:tag('th'):wikitext()
	
	local header1 = html:tag('tr'):addClass('headerRow-1')
	header1:tag('th'):wikitext('Name')
					 :attr('colspan', 2)
	header1:tag('th'):wikitext('DLC')
	if isWeapon == true then
		header1:tag('th'):wikitext('Attack<br>Speed')
	end

	-- Attack bonuses
	header1:tag('th'):wikitext(Icons.Icon({'Attack', type='skill', size=iconSize, notext='true'}))
	header1:tag('th'):wikitext(Icons.Icon({'Strength', type='skill', size=iconSize, notext='true'}))
	header1:tag('th'):wikitext(Icons.Icon({'Defence', type='skill', size=iconSize, notext='true'}))
	header1:tag('th'):wikitext(Icons.Icon({'Ranged', type='skill', size=iconSize, notext='true'}))
	header1:tag('th'):wikitext(Icons.Icon({'Magic', type='skill', size=iconSize, notext='true'}))

	--Strength bonuses
	header1:tag('th'):wikitext(Icons.Icon({'Strength', type='skill', size=iconSize, notext='true'}))
	header1:tag('th'):wikitext(Icons.Icon({'Ranged', type='skill', size=iconSize, notext='true'}))
	header1:tag('th'):wikitext(Icons.Icon({'Magic', type='skill', size=iconSize, notext='true'}))

	--Defence bonuses
	header1:tag('th'):wikitext(Icons.Icon({'Strength', type='skill', size=iconSize, notext='true'}))
	header1:tag('th'):wikitext(Icons.Icon({'Ranged', type='skill', size=iconSize, notext='true'}))
	header1:tag('th'):wikitext(Icons.Icon({'Magic', type='skill', size=iconSize, notext='true'}))
	
	-- Damage reduction
	header1:tag('th'):wikitext(Icons.Icon({'Damage Reduction', size=iconSize, notext='true'}))
	
	--Level requirements
	header1:tag('th'):wikitext('Equip Req')

	-- Fill the table with all items
	for _, item in ipairs(itemList) do
		local row = html:tag('tr')
		row:tag('td'):wikitext(Icons.Icon({item.name, type='item', notext=true}))
					 :attr('data-sort-value', item.name)
		row:tag('td'):wikitext(Icons.Icon({item.name, type='item', noicon=true}))
					 :attr('data-sort-value', item.name)
		row:tag('td'):wikitext(Icons.getDLCColumnIcon(item.id))
					 :attr('data-sort-value', Icons.getExpansionID(item.id))

		-- Add attack speed.
		if isWeapon == true then
			local atkSpeed = Items._getItemStat(item, 'attackSpeed')
			if atkSpeed > 0 then
				row:tag('td'):wikitext(Num.round(atkSpeed / 1000, 3, 1) .. 's')
							 :attr('data-sort-value', atkSpeed)
							 :css('text-align', 'right')
			else
				row:tag('td'):wikitext('N/A')
							 :addClass('table-na')
			end
		end
		
		-- Attack bonuses
		addStatCell(row, item, 'stabAttackBonus')
		addStatCell(row, item, 'slashAttackBonus')
		addStatCell(row, item, 'blockAttackBonus')
		addStatCell(row, item, 'rangedAttackBonus')
		addStatCell(row, item, 'magicAttackBonus')
		
		-- Strength bonuses
		addStatCell(row, item, 'meleeStrengthBonus')
		addStatCell(row, item, 'rangedStrengthBonus')
		addStatCell(row, item, 'magicDamageBonus'):wikitext('%')	

		-- Defence bonuses
		addStatCell(row, item, 'meleeDefenceBonus')
		addStatCell(row, item, 'rangedDefenceBonus')
		addStatCell(row, item, 'magicDefenceBonus')

		-- Add Damage Reduction / Abyssal Resistance
		addDRCell(row, item)

		local reqs = getRequirements(item)
		if reqs == nil then
			row:tag('td'):wikitext('None')
						 :attr('data-sort-value', 0)
		else
			row:tag('td'):wikitext(reqs.requirements)
						 :attr('data-sort-value', reqs.datasortvalue)
		end
	end

	return tostring(html)
end

function p.getCategoryTable(frame)
	local args = frame.args ~= nil and frame.args or frame
	local slot, style = args[1], Shared.titleCase(args[2] or '')
	local slotID = getSlotID(slot)
	if slotID == nil then
		return Shared.printError('Invalid slot ID: ' .. (slot or 'nil'))
	elseif style ~= nil and style ~= '' and itemStyleOverrides[style] == nil then
		return Shared.printError('Invalid style: ' .. (style or 'nil'))
	end

	return p.getEquipmentTable(getItems(slotID, style), slot)
end

function p.getTableForList(frame)
	local pFrame = frame:getParent()
	local frameArgs = pFrame.args ~= nil and pFrame.args or frame
	local includeModifiers = frameArgs.includeModifiers ~= nil and string.upper(frameArgs.includeModifiers) == 'TRUE' or false

	local itemList, errItems = {}, {}
	for i, rawItemName in ipairs(frameArgs) do
		local itemName = Shared.trim(rawItemName)
		local item = Items.getItem(itemName)
		if item == nil then
			table.insert(errItems, "'" .. itemName .. "'")
		else
			table.insert(itemList, item)
		end
	end
	
	if not Shared.tableIsEmpty(errItems) then
		return Shared.printError('Some items not found in database: ' .. table.concat(errItems, ', '))
	else
		return p.getEquipmentTable(itemList)
	end
end

function p.getDoubleLootTable(frame)
	local modsDL = {
		["id"] = {
			'melvorD:doubleItemsSkill',
			'melvorD:doubleItemsChanceAgainstDamageType'
		},
		["alias"] = {
			'increasedChanceToDoubleLootCombat',
			'decreasedChanceToDoubleLootCombat',
			'increasedChanceToDoubleItemsGlobal',
			'decreasedChanceToDoubleItemsGlobal'
		}
	}
	local matchCriteria = Modifiers.getMatchCriteriaFromIDs(modsDL.id, modsDL.alias)
	local itemMatchedMods = {}
	local itemList = Items.getItems(
		function(item)
			if item.modifiers ~= nil then
				local matchedMods = Modifiers.getMatchingModifiers(item.modifiers, matchCriteria)
				if Shared.tableIsEmpty(matchedMods.matched) then
					return false
				else
					itemMatchedMods[item.id] = matchedMods.matched
					return true
				end
			end
		end
	)

	local resultPart = {}
	table.insert(resultPart, '{| class="wikitable sortable stickyHeader"\r\n|-class="headerRow-0"')
	table.insert(resultPart, '\r\n!colspan="2"|Name!!Bonus!!Description')
	for i, item in Shared.skpairs(itemList) do
		local lootValue = Modifiers.getModifierValue(itemMatchedMods[item.id])

		table.insert(resultPart, '\r\n|-')
		table.insert(resultPart, '\r\n|data-sort-value="'..item.name..'"|'..Icons.Icon({item.name, type='item', size=50, notext=true}))
		table.insert(resultPart, '||' .. Icons.getExpansionIcon(item.id) .. Icons.Icon({item.name, type='item', noicon=true}))
		table.insert(resultPart, '||style ="text-align: right;" data-sort-value="'..lootValue..'"|'..lootValue..'%')
		table.insert(resultPart, '||' .. getItemDesc(item))
	end

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

function p.getItemUpgradeTable(frame)
	local args = frame.args ~= nil and frame.args or frame
	local category, usedItemName = args[1], args.usedItem
	local upgradeArray = {}
	local isEquipment = false
	
	local usedItemID = nil
	if usedItemName ~= nil and usedItemName ~= '' then
		local usedItem = Items.getItem(usedItemName)
		if usedItem == nil then
			return Shared.printError('Used item not found: ' .. usedItemName)
		end
		usedItemID = usedItem.id
	end
	
	local function upgradeConsumesItem(itemUpgrade, itemID)
		if itemID == nil then
			return true
		end	
		for i, itemCost in ipairs(itemUpgrade.itemCosts) do
			if itemCost.id == itemID then
				return true
			end
		end
		return false
	end

	if string.upper(category) == 'POTION' then
		upgradeArray = GameData.getEntities('itemUpgrades',
			function(upgrade)
				return Shared.contains(upgrade.upgradedItemID, 'Potion') and upgradeConsumesItem(upgrade, usedItemID)
			end
			)
	elseif string.upper(category) == 'OTHER' then
		upgradeArray = GameData.getEntities('itemUpgrades',
			function(upgrade)
				if not Shared.contains(upgrade.upgradedItemID, 'Potion') and upgradeConsumesItem(upgrade, usedItemID) then
					local item = Items.getItemByID(upgrade.upgradedItemID)
					if item ~= nil then
						return item.validSlots == nil or Shared.tableIsEmpty(item.validSlots)
					end
				end
				return false
			end
			)
	else
		-- If category is a slot name, convert it to the slot ID instead
		local slotID = getSlotID(category)
		if slotID ~= nil then
			local slotNS, slotLocalID = Shared.getLocalID(slotID)
			category = slotLocalID
		end

		if category == nil then
			return Shared.printError('Invalid option. Choose either an equipment slot, Potion, or Other')
		end
		isEquipment = true
		upgradeArray = GameData.getEntities('itemUpgrades',
			function(upgrade)
				if upgradeConsumesItem(upgrade, usedItemID) then
					local item = Items.getItemByID(upgrade.upgradedItemID)
					if item ~= nil then
						return item.validSlots ~= nil and Shared.contains(item.validSlots, category)
					end
				end
				return false
			end
			)
	end

	local useStatChange = isEquipment or (string.upper(category) == 'POTION')
	local resultPart = {}
	table.insert(resultPart, '{| class="wikitable sortable stickyHeader"')
	table.insert(resultPart, '\r\n|- class="headerRow-0"')
	table.insert(resultPart, '\r\n!colspan="2"|Upgraded Item!!Ingredients')
	if useStatChange then table.insert(resultPart, '!!Stat Change') end

	for i, upgrade in ipairs(upgradeArray) do
		local item = Items.getItemByID(upgrade.upgradedItemID)
		table.insert(resultPart, '\r\n|-')
		table.insert(resultPart, '\r\n|'..Icons.Icon({item.name, type='item', size='50', notext=true}))
		table.insert(resultPart, '||' .. Icons.getExpansionIcon(item.id) .. Icons.Icon({item.name, type='item', noicon=true}))

		table.insert(resultPart, '|| ' .. Common.getCostString({ items = upgrade.itemCosts, gp = upgrade.gpCost, sc = upgrade.scCost}, 'None'))

		if useStatChange then
			-- Generate stat change column
			local statChangeString = ''
			if not Shared.tableIsEmpty(upgrade.rootItemIDs) then
				-- Some items (e.g. FEZ) may have multiple root items. Simply use the first one
				local rootItem = Items.getItemByID(upgrade.rootItemIDs[1])
				if rootItem ~= nil then
					statChangeString = Items.getStatChangeString(item, rootItem)
				end
			end
			table.insert(resultPart, '|| '..statChangeString)
		end
	end

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

function p.getRuneProvidingItemTable(frame)
	local itemArray = Items.getItems(function(item) return item.providedRunes ~= nil end)
	
	local resultPart = {}
	table.insert(resultPart, '{| class="wikitable sortable stickyHeader"')
	table.insert(resultPart, '\r\n|- class="headerRow-0"')
	table.insert(resultPart, '\r\n!colspan="2"|Item!!Runes Provided')
	
	for i, item in pairs(itemArray) do
		local PR = item.providedRunes
		table.insert(resultPart, '\r\n|-')
		table.insert(resultPart, '\r\n|style="text-align: centre;"|'..Icons.Icon({item.name, type='item', size=50, notext=true}))
		table.insert(resultPart, '\r\n|' .. Icons.getExpansionIcon(item.id) .. Icons.Icon({item.name, type='item', noicon=true}))
		local runeLines = {}
		local sortVal = ''
		for j, runePair in pairs(PR) do
			local runeID = runePair.id
			local qty = runePair.quantity
			local rune = Items.getItemByID(runeID)
			sortVal = sortVal..rune.name..qty
			table.insert(runeLines, Icons.Icon({rune.name, type='item', qty=qty}))
		end
		table.insert(resultPart, '\r\n|data-sort-value="'..sortVal..'"|'..table.concat(runeLines, '<br/>'))
	end
	
	table.insert(resultPart, '\r\n|}')
	return table.concat(resultPart)
end

function p._getDRTable(slots, items)
	local resultPart = {}
	
	table.insert(resultPart, '{| class="wikitable sortable" style="width:90%;"')
	table.insert(resultPart, '\r\n|-')
	table.insert(resultPart, '\r\n! style="width:4%"|DR%')
	for i, slot in pairs(slots) do
		table.insert(resultPart, '\r\n! '..slot)
	end
	
	local DRTable = {}
	table.sort(items, function(a, b) return a.name < b.name end)
	
	for i, item in pairs(items) do
		local DR = Items._getItemStat(item, 'damageReduction', true)
		local EquipSlot = Items._getItemEquipSlot(item)
		if DRTable[DR] == nil then
			DRTable[DR] = {}
		end
		if DRTable[DR][EquipSlot] == nil then
			DRTable[DR][EquipSlot] = {}
		end
		table.insert(DRTable[DR][EquipSlot], Icons.Icon({item.name, type='item', expicon = Icons.getExpansionIcon(item.id)}))
	end
	
	for DR, SlotTables in Shared.skpairs(DRTable) do
		table.insert(resultPart, '\r\n|-\r\n|'..DR..'%')
		for i, SlotName in pairs(slots) do
			table.insert(resultPart, '\r\n|')
			if SlotTables[SlotName] ~= nil then
				table.insert(resultPart, table.concat(SlotTables[SlotName], '<br/>'))
			end
		end
	end
	
	table.insert(resultPart, '\r\n|}')
	
	return table.concat(resultPart)
end

function p.getDRTable(frame)
	local style = frame.args ~= nil and frame.args[1] or frame
	local slotNames = {}
	if style == 'Other' then
		slotNames = {'Helmet', 'Platelegs', 'Gloves', 'Shield', 'Cape', 'Amulet', 'Ring'}
	else
		slotNames = {'Helmet', 'Platebody', 'Platelegs', 'Boots', 'Gloves', 'Weapon', 'Shield'}
	end
	
	local itemList = getEquipItemList(
		function(entry)
			if Items._getItemStat(entry.item, 'damageReduction', true) <= 0 then
				-- Item provides no DR: Exclude
				return false
			end

			-- Check equipment slot matches
			local slotMatch = false
			for _, slotName in ipairs(slotNames) do
				if entry.slots[slotName] ~= nil then
					slotMatch = true
					break
				end
			end
			if not slotMatch then
				return false
			end

			-- Finally, ensure the style matches. If no style specified then return evertyhing
			return (style == nil or style == '' or style == entry.style)
		end
	)

	return p._getDRTable(slotNames, itemList)
end

return p