Module:StreetBrawlItems: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
Wrap rendered groups in flex rows so cards and hero icons have consistent spacing (with help from vergir-bot LLM)
Vergir (talk | contribs)
m make item cards on the Street Brawl section sliiiightly bigger (+5%)
 
(26 intermediate revisions by 4 users not shown)
Line 5: Line 5:
local heroes = mw.loadJsonData("Data:HeroData.json")
local heroes = mw.loadJsonData("Data:HeroData.json")


local NAME_TO_KEY, KEY_TO_NAME, RELEASED = {}, {}, {}
-- Slot display order: Weapon -> Vitality (Armor) -> Spirit (Tech)
local SLOT_RANK = { Weapon = 1, Armor = 2, Tech = 3 }
-- Card scale passed to ItemBox.
local CARD_SIZE = "0.7"
 
local NAME_TO_KEY = {}
for key, h in pairs(heroes) do
for key, h in pairs(heroes) do
if type(h) == "table" and h.Name then
if type(h) == "table" and h.Name then
KEY_TO_NAME[key] = h.Name
NAME_TO_KEY[mw.ustring.lower(h.Name)] = key
NAME_TO_KEY[mw.ustring.lower(h.Name)] = key
if not h.InDevelopment and not h.IsDisabled then RELEASED[key] = true end
end
end
end
end
Line 23: Line 26:


local function iname(code) local it = items[code]; return it and it.Name or nil end
local function iname(code) local it = items[code]; return it and it.Name or nil end
local function iok(code) local it = items[code]; return it ~= nil and it.Name ~= nil end
-- A "real" item: exists, has a display name, and is not disabled/removed.
local function iok(code)
local it = items[code]
return it ~= nil and it.Name ~= nil and it.IsDisabled ~= true
end
local function itier(code)
local function itier(code)
local it = items[code]; if not it then return nil end
local it = items[code]; if not it then return nil end
if it.StreetBrawl == true then return 5 end
if it.StreetBrawl == true then return 5 end
return it.Tier
return it.Tier
end
local function islot(code) local it = items[code]; return it and it.Slot end
local function icost(code)
local it = items[code]; local c = it and it.Cost
return type(c) == "number" and c or math.huge
end
-- Sort order: slot (Weapon -> Armor -> Tech), then cost ascending, then name.
local function item_less(a, b)
local ra = SLOT_RANK[islot(a)] or 99
local rb = SLOT_RANK[islot(b)] or 99
if ra ~= rb then return ra < rb end
local ca, cb = icost(a), icost(b)
if ca ~= cb then return ca < cb end
return (iname(a) or a) < (iname(b) or b)
end
end


local function render_item(frame, code, style)
local function render_item(frame, code)
local name = iname(code); if not name then return nil end
local name = iname(code); if not name then return nil end
if style == "icon" then return frame:expandTemplate{title = "ItemIcon", args = {name}} end
-- These hero pages place hundreds of cards at once, so the cards render in
return frame:expandTemplate{title = "ItemBox", args = {name, showTierTag = "true"}}
-- lowGraphics mode: ItemBox swaps its paper texture for a single cacheable
end
-- overlay, which keeps scrolling smooth. See [[Template:ItemBox]].
local function render_hero(frame, key)
local args = { name, size = CARD_SIZE, lowGraphics = "true" }
local name = KEY_TO_NAME[key]; if not name then return nil end
-- Legendary (tier 5) cards omit the tier badge; it is redundant in the Legendary tier.
return frame:expandTemplate{title = "HeroIcon", args = {name}}
if itier(code) ~= 5 then args.showTierTag = "true" end
return frame:expandTemplate{title = "ItemBox", args = args}
end
end


-- Lays rendered members out in a wrapping flex row with a consistent gap.
-- Lays rendered members out in a container with the given class (styled via CSS).
local function flexrow(parts, gap, center)
local function classdiv(parts, class)
if #parts == 0 then return "" end
if #parts == 0 then return "" end
local align = center and "center" or "flex-start"
return '<div class="' .. class .. '">' .. table.concat(parts) .. '</div>'
return '<div style="display:flex; flex-wrap:wrap; gap:' .. gap .. '; align-items:' .. align .. '">'
.. table.concat(parts) .. '</div>'
end
end


-- Wraps a rendered row with an optional bold label / italic note, or a collapsible box.
-- A tier/section heading: a plain div carrying the skin's own heading wrapper
-- Returns "" when the row is empty, so nothing (not even the label) shows.
-- classes, so it inherits real heading styling while staying out of the table
local function wrap(inner, label, note, collapse)
-- of contents (and can be hidden inside the gate).
if inner == "" then return "" end
local function heading_div(label)
if collapse then
return '<div class="mw-heading mw-heading4 sb-tier-head">' .. label .. '</div>'
local header = (label and label ~= "") and label or "&nbsp;"
return '{| class="wikitable mw-collapsible mw-collapsed" style="width:100%"\n'
.. '! style="text-align:left" | ' .. header .. '\n|-\n| ' .. inner .. '\n|}'
end
local pre = ""
if label and label ~= "" then pre = pre .. "'''" .. label .. "'''<br>" end
if note and note ~= "" then pre = pre .. "''" .. note .. "''<br>" end
return pre .. inner
end
end


local function hero_sets(hkey)
local function hero_sets(hkey)
local favored, unfavored, special, counter = {}, {}, {}, {}
local favored, unfavored, counter = {}, {}, {}
for _, o in ipairs(sb.Outliers) do
for _, o in ipairs(sb.Outliers) do
if o.Hero == hkey then
if o.Hero == hkey then
if o.Bucket == "Good" then favored[o.Item] = true; special[o.Item] = true
if o.Bucket == "Good" then favored[o.Item] = true
elseif type(o.Weight) == "number" and o.Weight < 1 then unfavored[o.Item] = true; special[o.Item] = true end
elseif type(o.Weight) == "number" and o.Weight < 1 then unfavored[o.Item] = true end
if o.Counter ~= nil then counter[#counter + 1] = o.Item end
if o.Counter ~= nil then counter[#counter + 1] = o.Item end
end
end
end
end
return favored, unfavored, special, counter
return favored, unfavored, counter
end
end


local function item_code(input)
-- Returns sorted high (favored), average (pool), low (unfavored) item-code lists for a tier.
input = mw.text.trim(input or "")
local function collect(hkey, tier)
if input == "" then return nil end
local favored, unfavored = hero_sets(hkey)
if items[input] then return input end
local high, average, low = {}, {}, {}
for k, v in pairs(items) do
for _, c in ipairs(sb.AvailableItems) do
if type(v) == "table" and v.Name == input and v.IsDisabled ~= true then return k end
if iok(c) and (tier == nil or itier(c) == tier) then
if favored[c] then high[#high + 1] = c
elseif unfavored[c] then low[#low + 1] = c
else average[#average + 1] = c end
end
end
table.sort(high, item_less)
table.sort(average, item_less)
table.sort(low, item_less)
return high, average, low
end
 
-- Builds one tier as conditional columns:
--  High chance (favored) takes 35%, the average column fills the rest.
--  Low chance (unfavored) is dropped when empty; a narrow column on the right when
--  High is present; a fit-content column on the right when High is absent.
local function tier_columns(frame, hkey, tier, hi, avg, lo)
local high, average, low = collect(hkey, tier)
 
local function col(codes, cls, head)
local parts = {}
for _, c in ipairs(codes) do local r = render_item(frame, c); if r then parts[#parts + 1] = r end end
return '<div class="sb-col ' .. cls .. '">'
.. '<div class="sb-col-head">' .. head .. '</div>'
.. '<div class="sb-col-body sb-col-cards">' .. table.concat(parts) .. '</div>'
.. '</div>'
end
 
local out = {}
if #high > 0 then
out[#out + 1] = col(high, "sb-col-high sb-w-high", hi)
end
end
for k, v in pairs(items) do
out[#out + 1] = col(average, "sb-col-average sb-w-fill", avg)
if type(v) == "table" and v.Name == input then return k end
if #low > 0 then
local low_w = (#high > 0) and "sb-low-narrow" or "sb-low-wide"
out[#out + 1] = col(low, "sb-col-low " .. low_w, lo)
end
end
return nil
return '<div class="sb-tier">' .. table.concat(out) .. '</div>'
end
end


-- {{#invoke:StreetBrawlItems|hero|HERO|GROUP|TIER|label=..|collapse=yes|style=card|icon}}
-- {{#invoke:StreetBrawlItems|tiers|HERO|gate=ID|high=..|average=..|low=..|tier1=..|...|tier5=..}}
-- GROUP = favored | unfavored | common  (use hero_counter for counters)
-- All five tiers under styled (non-TOC) headings. All display text is supplied by the
function p.hero(frame)
-- caller. With gate=ID, the Tier 1 heading and everything after it is wrapped in an open
-- <div id=ID class="sb-gate">, whose inner <div class="sb-gate-body"> the caller must
-- close after any trailing content. The id sits on the outer wrapper so following the
-- anchor lands on the Tier 1 heading rather than the columns below it.
function p.tiers(frame)
local hkey = hero_key(frame.args[1]); if not hkey then return "" end
local hkey = hero_key(frame.args[1]); if not hkey then return "" end
local group = mw.text.trim(frame.args[2] or "")
local hi = frame.args.high or ""
local tier = tonumber(frame.args[3])
local avg = frame.args.average or ""
local style = frame.args.style
local lo = frame.args.low or ""
if not style or style == "" then style = (group == "common") and "icon" or "card" end
local gate = frame.args.gate
local collapse = (frame.args.collapse == "yes" or frame.args.collapse == "true")
if gate == "" then gate = nil end


local favored, unfavored, special = hero_sets(hkey)
local out = {}
local pred
for t = 1, 5 do
if group == "favored" then pred = function(c) return favored[c] end
local head = heading_div(frame.args["tier" .. t] or "")
elseif group == "unfavored" then pred = function(c) return unfavored[c] end
local cols = tier_columns(frame, hkey, t, hi, avg, lo)
elseif group == "common" then pred = function(c) return not special[c] end
if t == 1 and gate then
else return "" end
out[#out + 1] = '<div id="' .. gate .. '" class="sb-gate">\n'
 
.. head .. "\n"
local codes = {}
.. '<div class="sb-gate-body">\n'
for _, c in ipairs(sb.AvailableItems) do
.. cols
if iok(c) and (tier == nil or itier(c) == tier) and pred(c) then codes[#codes + 1] = c end
else
out[#out + 1] = head .. "\n" .. cols
end
end
end
table.sort(codes, function(a, b) return (iname(a) or a) < (iname(b) or b) end)
return table.concat(out, "\n\n")
 
local parts = {}
for _, c in ipairs(codes) do local r = render_item(frame, c, style); if r then parts[#parts + 1] = r end end
local is_icon = (style == "icon")
local inner = flexrow(parts, is_icon and "4px 10px" or "8px", is_icon)
return wrap(inner, frame.args.label, nil, collapse)
end
end


-- {{#invoke:StreetBrawlItems|hero_counter|HERO|heading=..|note=..}}
-- {{#invoke:StreetBrawlItems|hero_counter|HERO|heading=..|note=..}}
-- Returns the whole counter block (heading + note + cards), or "" when empty.
-- The whole counter block (heading + note + cards), or "" when the hero has none.
function p.hero_counter(frame)
function p.hero_counter(frame)
local hkey = hero_key(frame.args[1]); if not hkey then return "" end
local hkey = hero_key(frame.args[1]); if not hkey then return "" end
local _, _, _, counter = hero_sets(hkey)
local _, _, counter = hero_sets(hkey)


local codes = {}
local codes = {}
for _, c in ipairs(counter) do if iok(c) then codes[#codes + 1] = c end end
for _, c in ipairs(counter) do if iok(c) then codes[#codes + 1] = c end end
if #codes == 0 then return "" end
if #codes == 0 then return "" end
table.sort(codes, function(a, b) return (iname(a) or a) < (iname(b) or b) end)
table.sort(codes, item_less)


local parts = {}
local parts = {}
for _, c in ipairs(codes) do local r = render_item(frame, c, "card"); if r then parts[#parts + 1] = r end end
for _, c in ipairs(codes) do local r = render_item(frame, c); if r then parts[#parts + 1] = r end end


local out = {}
local out = {}
local heading = frame.args.heading
local heading = frame.args.heading
if heading and heading ~= "" then out[#out + 1] = "=== " .. heading .. " ===" end
if heading and heading ~= "" then out[#out + 1] = heading_div(heading) end
local note = frame.args.note
local note = frame.args.note
if note and note ~= "" then out[#out + 1] = "''" .. note .. "''" end
if note and note ~= "" then out[#out + 1] = "''" .. note .. "''" end
out[#out + 1] = flexrow(parts, "8px", false)
out[#out + 1] = classdiv(parts, "sb-cards")
return table.concat(out, "\n")
return table.concat(out, "\n")
end
end


-- {{#invoke:StreetBrawlItems|item|ITEM|GROUP|label=..|note=..}}
-- {{#invoke:StreetBrawlItems|counters_table}}
-- GROUP = favored | unfavored | counter
-- Every hero that has counter items, one row each, sorted by hero name. Items
function p.item(frame)
-- within a row are listed alphabetically. Built as HTML rather than pipe-table
local code = item_code(frame.args[1]); if not code then return "" end
-- markup so the invoke does not have to sit at the start of a line.
local group = mw.text.trim(frame.args[2] or "")
function p.counters_table(frame)
 
local by_hero, rows = {}, {}
local fav, unf, ctr = {}, {}, {}
for _, o in ipairs(sb.Outliers) do
for _, o in ipairs(sb.Outliers) do
if o.Item == code and RELEASED[o.Hero] then
if o.Counter ~= nil and iok(o.Item) then
if o.Bucket == "Good" then fav[#fav + 1] = o.Hero
local hero = heroes[o.Hero]
elseif type(o.Weight) == "number" and o.Weight < 1 then unf[#unf + 1] = o.Hero end
if hero and hero.Name then
if o.Counter ~= nil then ctr[#ctr + 1] = o.Hero end
local row = by_hero[o.Hero]
if not row then
row = { name = hero.Name, items = {}, seen = {} }
by_hero[o.Hero] = row
rows[#rows + 1] = row
end
-- Guard against a hero/item pair being listed twice.
if not row.seen[o.Item] then
row.seen[o.Item] = true
row.items[#row.items + 1] = iname(o.Item)
end
end
end
end
end
end
local list = (group == "favored" and fav) or (group == "unfavored" and unf) or (group == "counter" and ctr)
if #rows == 0 then return "" end
if not list then return "" end
table.sort(rows, function(a, b) return a.name < b.name end)
table.sort(list, function(a, b) return (KEY_TO_NAME[a] or a) < (KEY_TO_NAME[b] or b) end)


local parts = {}
local tbl = mw.html.create("table"):addClass("wikitable")
for _, k in ipairs(list) do local r = render_hero(frame, k); if r then parts[#parts + 1] = r end end
local head = tbl:tag("tr")
local inner = flexrow(parts, "4px 10px", true)
head:tag("th"):wikitext("Hero")
return wrap(inner, frame.args.label, frame.args.note, false)
head:tag("th"):wikitext("Items")
end


-- {{#invoke:StreetBrawlItems|item_in_pool|ITEM}} -> "1" if in the Street Brawl pool, else ""
for _, row in ipairs(rows) do
function p.item_in_pool(frame)
table.sort(row.items)
local code = item_code(frame.args[1]); if not code then return "" end
local icons = {}
for _, c in ipairs(sb.AvailableItems) do if c == code then return "1" end end
for _, n in ipairs(row.items) do
return ""
icons[#icons + 1] = frame:expandTemplate{title = "ItemIcon", args = {n}}
end
local tr = tbl:tag("tr")
tr:tag("td"):wikitext(frame:expandTemplate{title = "HeroIcon", args = {row.name}})
tr:tag("td"):wikitext(table.concat(icons, ", "))
end
return tostring(tbl)
end
end


return p
return p

Latest revision as of 21:25, 1 August 2026

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

local p = {}

local sb = mw.loadJsonData("Data:StreetBrawlData.json")["item-buckets"]
local items = mw.loadJsonData("Data:ItemData.json")
local heroes = mw.loadJsonData("Data:HeroData.json")

-- Slot display order: Weapon -> Vitality (Armor) -> Spirit (Tech)
local SLOT_RANK = { Weapon = 1, Armor = 2, Tech = 3 }
-- Card scale passed to ItemBox.
local CARD_SIZE = "0.7"

local NAME_TO_KEY = {}
for key, h in pairs(heroes) do
	if type(h) == "table" and h.Name then
		NAME_TO_KEY[mw.ustring.lower(h.Name)] = key
	end
end

local function hero_key(input)
	if not input then return nil end
	input = mw.text.trim(input)
	if input == "" then return nil end
	if heroes[input] then return input end
	return NAME_TO_KEY[mw.ustring.lower(input)]
end

local function iname(code) local it = items[code]; return it and it.Name or nil end
-- A "real" item: exists, has a display name, and is not disabled/removed.
local function iok(code)
	local it = items[code]
	return it ~= nil and it.Name ~= nil and it.IsDisabled ~= true
end
local function itier(code)
	local it = items[code]; if not it then return nil end
	if it.StreetBrawl == true then return 5 end
	return it.Tier
end
local function islot(code) local it = items[code]; return it and it.Slot end
local function icost(code)
	local it = items[code]; local c = it and it.Cost
	return type(c) == "number" and c or math.huge
end

-- Sort order: slot (Weapon -> Armor -> Tech), then cost ascending, then name.
local function item_less(a, b)
	local ra = SLOT_RANK[islot(a)] or 99
	local rb = SLOT_RANK[islot(b)] or 99
	if ra ~= rb then return ra < rb end
	local ca, cb = icost(a), icost(b)
	if ca ~= cb then return ca < cb end
	return (iname(a) or a) < (iname(b) or b)
end

local function render_item(frame, code)
	local name = iname(code); if not name then return nil end
	-- These hero pages place hundreds of cards at once, so the cards render in
	-- lowGraphics mode: ItemBox swaps its paper texture for a single cacheable
	-- overlay, which keeps scrolling smooth. See [[Template:ItemBox]].
	local args = { name, size = CARD_SIZE, lowGraphics = "true" }
	-- Legendary (tier 5) cards omit the tier badge; it is redundant in the Legendary tier.
	if itier(code) ~= 5 then args.showTierTag = "true" end
	return frame:expandTemplate{title = "ItemBox", args = args}
end

-- Lays rendered members out in a container with the given class (styled via CSS).
local function classdiv(parts, class)
	if #parts == 0 then return "" end
	return '<div class="' .. class .. '">' .. table.concat(parts) .. '</div>'
end

-- A tier/section heading: a plain div carrying the skin's own heading wrapper
-- classes, so it inherits real heading styling while staying out of the table
-- of contents (and can be hidden inside the gate).
local function heading_div(label)
	return '<div class="mw-heading mw-heading4 sb-tier-head">' .. label .. '</div>'
end

local function hero_sets(hkey)
	local favored, unfavored, counter = {}, {}, {}
	for _, o in ipairs(sb.Outliers) do
		if o.Hero == hkey then
			if o.Bucket == "Good" then favored[o.Item] = true
			elseif type(o.Weight) == "number" and o.Weight < 1 then unfavored[o.Item] = true end
			if o.Counter ~= nil then counter[#counter + 1] = o.Item end
		end
	end
	return favored, unfavored, counter
end

-- Returns sorted high (favored), average (pool), low (unfavored) item-code lists for a tier.
local function collect(hkey, tier)
	local favored, unfavored = hero_sets(hkey)
	local high, average, low = {}, {}, {}
	for _, c in ipairs(sb.AvailableItems) do
		if iok(c) and (tier == nil or itier(c) == tier) then
			if favored[c] then high[#high + 1] = c
			elseif unfavored[c] then low[#low + 1] = c
			else average[#average + 1] = c end
		end
	end
	table.sort(high, item_less)
	table.sort(average, item_less)
	table.sort(low, item_less)
	return high, average, low
end

-- Builds one tier as conditional columns:
--   High chance (favored) takes 35%, the average column fills the rest.
--   Low chance (unfavored) is dropped when empty; a narrow column on the right when
--   High is present; a fit-content column on the right when High is absent.
local function tier_columns(frame, hkey, tier, hi, avg, lo)
	local high, average, low = collect(hkey, tier)

	local function col(codes, cls, head)
		local parts = {}
		for _, c in ipairs(codes) do local r = render_item(frame, c); if r then parts[#parts + 1] = r end end
		return '<div class="sb-col ' .. cls .. '">'
			.. '<div class="sb-col-head">' .. head .. '</div>'
			.. '<div class="sb-col-body sb-col-cards">' .. table.concat(parts) .. '</div>'
			.. '</div>'
	end

	local out = {}
	if #high > 0 then
		out[#out + 1] = col(high, "sb-col-high sb-w-high", hi)
	end
	out[#out + 1] = col(average, "sb-col-average sb-w-fill", avg)
	if #low > 0 then
		local low_w = (#high > 0) and "sb-low-narrow" or "sb-low-wide"
		out[#out + 1] = col(low, "sb-col-low " .. low_w, lo)
	end
	return '<div class="sb-tier">' .. table.concat(out) .. '</div>'
end

-- {{#invoke:StreetBrawlItems|tiers|HERO|gate=ID|high=..|average=..|low=..|tier1=..|...|tier5=..}}
-- All five tiers under styled (non-TOC) headings. All display text is supplied by the
-- caller. With gate=ID, the Tier 1 heading and everything after it is wrapped in an open
-- <div id=ID class="sb-gate">, whose inner <div class="sb-gate-body"> the caller must
-- close after any trailing content. The id sits on the outer wrapper so following the
-- anchor lands on the Tier 1 heading rather than the columns below it.
function p.tiers(frame)
	local hkey = hero_key(frame.args[1]); if not hkey then return "" end
	local hi = frame.args.high or ""
	local avg = frame.args.average or ""
	local lo = frame.args.low or ""
	local gate = frame.args.gate
	if gate == "" then gate = nil end

	local out = {}
	for t = 1, 5 do
		local head = heading_div(frame.args["tier" .. t] or "")
		local cols = tier_columns(frame, hkey, t, hi, avg, lo)
		if t == 1 and gate then
			out[#out + 1] = '<div id="' .. gate .. '" class="sb-gate">\n'
				.. head .. "\n"
				.. '<div class="sb-gate-body">\n'
				.. cols
		else
			out[#out + 1] = head .. "\n" .. cols
		end
	end
	return table.concat(out, "\n\n")
end

-- {{#invoke:StreetBrawlItems|hero_counter|HERO|heading=..|note=..}}
-- The whole counter block (heading + note + cards), or "" when the hero has none.
function p.hero_counter(frame)
	local hkey = hero_key(frame.args[1]); if not hkey then return "" end
	local _, _, counter = hero_sets(hkey)

	local codes = {}
	for _, c in ipairs(counter) do if iok(c) then codes[#codes + 1] = c end end
	if #codes == 0 then return "" end
	table.sort(codes, item_less)

	local parts = {}
	for _, c in ipairs(codes) do local r = render_item(frame, c); if r then parts[#parts + 1] = r end end

	local out = {}
	local heading = frame.args.heading
	if heading and heading ~= "" then out[#out + 1] = heading_div(heading) end
	local note = frame.args.note
	if note and note ~= "" then out[#out + 1] = "''" .. note .. "''" end
	out[#out + 1] = classdiv(parts, "sb-cards")
	return table.concat(out, "\n")
end

-- {{#invoke:StreetBrawlItems|counters_table}}
-- Every hero that has counter items, one row each, sorted by hero name. Items
-- within a row are listed alphabetically. Built as HTML rather than pipe-table
-- markup so the invoke does not have to sit at the start of a line.
function p.counters_table(frame)
	local by_hero, rows = {}, {}
	for _, o in ipairs(sb.Outliers) do
		if o.Counter ~= nil and iok(o.Item) then
			local hero = heroes[o.Hero]
			if hero and hero.Name then
				local row = by_hero[o.Hero]
				if not row then
					row = { name = hero.Name, items = {}, seen = {} }
					by_hero[o.Hero] = row
					rows[#rows + 1] = row
				end
				-- Guard against a hero/item pair being listed twice.
				if not row.seen[o.Item] then
					row.seen[o.Item] = true
					row.items[#row.items + 1] = iname(o.Item)
				end
			end
		end
	end
	if #rows == 0 then return "" end
	table.sort(rows, function(a, b) return a.name < b.name end)

	local tbl = mw.html.create("table"):addClass("wikitable")
	local head = tbl:tag("tr")
	head:tag("th"):wikitext("Hero")
	head:tag("th"):wikitext("Items")

	for _, row in ipairs(rows) do
		table.sort(row.items)
		local icons = {}
		for _, n in ipairs(row.items) do
			icons[#icons + 1] = frame:expandTemplate{title = "ItemIcon", args = {n}}
		end
		local tr = tbl:tag("tr")
		tr:tag("td"):wikitext(frame:expandTemplate{title = "HeroIcon", args = {row.name}})
		tr:tag("td"):wikitext(table.concat(icons, ", "))
	end
	return tostring(tbl)
end

return p