From Arms of God Wiki
bot: operator iteration 2026-06-10d (hover tooltips / filter chips + DPS sort / damage-type hubs / stat reverse-lookup pages) |
docs: clarify sentinel scoping comment |
||
| (6 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
-- Module:Compare — comparison | -- Module:Compare — sortable side-by-side comparison tables. | ||
-- | -- | ||
-- ( | -- WHAT IT DOES | ||
-- | -- Builds the big comparison tables (used on the Weapons index, the | ||
-- | -- Enemies index, and the Weapon comparison / Hero comparison pages; | ||
-- | -- 'Enemy comparison' is a redirect to Enemies since 2026-06-10f), | ||
-- | -- including the COMPUTED columns that are not stored anywhere: | ||
-- | -- * Weapon DPS = (Damage + Holy + Fire + Electric) x Attack Speed, | ||
-- rounded to 1 decimal. | |||
-- * Enemy wave-10 projections = base + per-wave x 9. | |||
-- | |||
-- HOW TO INVOKE | |||
-- {{#invoke:Compare|render|Weapons_Melee}} melee weapons, DPS desc | |||
-- {{#invoke:Compare|render|Weapons_Ranged}} ranged weapons, DPS desc | |||
-- {{#invoke:Compare|render|Enemies}} base + per-wave + wave-10 | |||
-- {{#invoke:Compare|render|Characters}} hero stat modifiers | |||
-- | |||
-- SOURCE DATA IT READS (via Module:Core) | |||
-- Data:Weapons.json, Data:Enemies.json, Data:Characters.json | |||
-- | |||
-- EDITING NOTES | |||
-- * Every table opens with a Thumbnail column (icon only, header marked | |||
-- class="unsortable") followed by a sortable linked Name column. | |||
-- * Stat sentinels apply ONLY to the weapon Pierce/Bounce columns | |||
-- (numcell): 1000 -> infinity sign (unlimited), <= -1000 -> em-dash. | |||
-- Every other column (incl. Range, enemy HP/Wave, hero stats) uses | |||
-- plaincell and shows the real number, however large. | |||
-- * Each row carries data-element / data-class / data-tier / data-type | |||
-- attributes — MediaWiki:Common.js uses them for the filter chips | |||
-- above the tables. Removing them breaks the chip filtering. | |||
-- * To add a column, add a header in the relevant *Rows() builder and | |||
-- a matching cell in the same position. | |||
local Core = require('Module:Core') | |||
local p = {} | local p = {} | ||
local TBL_STYLE = 'font-size:0.92em;background:var(--table-row-odd, #1b1c20);color:var(--table-text, #e6e6e6);border-color:var(--table-border, #3a3c44);' | local TBL_STYLE = 'font-size:0.92em;background:var(--table-row-odd, #1b1c20);color:var(--table-text, #e6e6e6);border-color:var(--table-border, #3a3c44);' | ||
local TH_STYLE = 'background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);' | local TH_STYLE = 'background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);' | ||
-- Colored element swatch for column headers. When the damage-type hub | |||
-- page exists (existence-checked via Core.statPageTarget) the label links | |||
-- to it; an inner span keeps the element color inside the anchor. | |||
local function swatch(label, tok, hexv, statName) | |||
local inner = label | |||
local target = statName and Core.statPageTarget(statName) | |||
if target then | |||
inner = '[[' .. target .. '|<span style="color:var(' .. tok .. ', ' | |||
.. hexv .. ');font-weight:bold;">' .. label .. '</span>]]' | |||
end | |||
return '<span style="color:var(' .. tok .. ', ' .. hexv | |||
.. ');font-weight:bold;white-space:nowrap;">● ' .. inner .. '</span>' | |||
end | |||
-- Abbreviated column headers -> the stat key whose page they should link | |||
-- to. Full-name headers resolve via Core.statPageTarget directly. | |||
local HDR_STAT = { | |||
['Atk Speed'] = 'Attack Speed', | |||
['Crit'] = 'Critical Chance', | |||
['Proj.'] = 'Projectiles', | |||
} | |||
-- Link a plain-text column header to its stat reference page when one | |||
-- exists; non-stat headers (and already-HTML headers like the swatches) | |||
-- pass through untouched. Page-less stats stay plain — never a redlink. | |||
local function headerLink(h) | |||
if h:find('[<%[]') then return h end | |||
local target = Core.statPageTarget(HDR_STAT[h] or h) | |||
if not target then return h end | |||
if target == h then return '[[' .. h .. ']]' end | |||
return '[[' .. target .. '|' .. h .. ']]' | |||
end | |||
-- Coerce a raw stat value to {display, sortval}: '15%' keeps the % | |||
-- sign for display but sorts by the number 15. | |||
local function num(v) | |||
if v == nil then return '—', nil end | |||
if type(v) == 'string' then | |||
local s = v:gsub('^%s+', ''):gsub('%s+$', '') | |||
local n = tonumber(s) or tonumber(s:match('^([%-%d%.]+)%%$')) | |||
if n == nil then return s, nil end | |||
if s:sub(-1) == '%' then return s, n end | |||
v = n | |||
end | |||
if type(v) == 'number' then | |||
if v >= 1000 then return '∞', 1e9 end | |||
if v <= -1000 then return '—', -1 end | |||
return Core.fmtNum(v), v | |||
end | |||
return tostring(v), nil | |||
end | |||
local function numcell(v) | |||
local t, s = num(v) | |||
return {t = t, s = s} | |||
end | |||
-- Sentinel-FREE number coercion. The >=1000 / <=-1000 sentinels in num() | |||
-- are a WEAPON convention (Pierce 1000 = unlimited, Bounce -1000 = hidden); | |||
-- enemy/character stats use real large numbers (e.g. a boss's HP/Wave of | |||
-- 5000), so those tables must format plainly or 5000 wrongly renders as inf. | |||
local function plain(v) | |||
if v == nil then return '—', nil end | |||
if type(v) == 'string' then | |||
local s = v:gsub('^%s+', ''):gsub('%s+$', '') | |||
local n = tonumber(s) or tonumber(s:match('^([%-%d%.]+)%%$')) | |||
if n == nil then return s, nil end | |||
if s:sub(-1) == '%' then return s, n end | |||
v = n | |||
end | |||
if type(v) == 'number' then return Core.fmtNum(v), v end | |||
return tostring(v), nil | |||
end | |||
local function plaincell(v) | |||
local t, s = plain(v) | |||
return {t = t, s = s} | |||
end | |||
local DMG = { | |||
Holy = {'--dmg-holy', '#e8c34a', 'wm-dmg-holy'}, | |||
Fire = {'--dmg-fire', '#ff6b35', 'wm-dmg-fire'}, | |||
Electric = {'--dmg-electric', '#4ea3ff', 'wm-dmg-electric'}, | |||
} | |||
local function chipcell(dmg, v) | |||
local t, s = num(v) | |||
if s == nil or s <= 0 or t == '—' then return {t = '—', s = 0} end | |||
local c = DMG[dmg] | |||
return {t = '<span class="' .. c[3] .. '" style="color:var(' .. c[1] .. ', ' | |||
.. c[2] .. ');font-weight:bold;">' .. t .. '</span>', s = s} | |||
end | |||
-- Header marker for the icon-only Thumbnail column: the renderer emits | |||
-- class="unsortable" on this th so the sort arrows skip it. | |||
local THUMB = {'Thumbnail', unsortable = true} | |||
-- Icon-only Thumbnail cell (links to the entity page; em-dash when the | |||
-- record has no icon). 48px matches the category-index thumbnail size. | |||
local function iconcell(rec) | |||
if rec.icon and rec.icon ~= '' then | |||
local slug = rec.slug or rec.name or '' | |||
return {t = '[[File:' .. rec.icon .. '|48px|link=' .. slug | |||
.. '|alt=' .. (rec.name or slug) .. ']]'} | |||
end | |||
return {t = '—'} | |||
end | |||
-- Linked-name cell, sortable by lowercased name, wrapped in .wm-tip | |||
-- (Common.js hover-infobox popup). | |||
local function namecell(rec) | |||
local slug = rec.slug or rec.name or '' | |||
local name = rec.name or slug | |||
local t = '<span class="wm-tip" data-tip-title="' .. slug .. '">[[' | |||
.. slug .. '|' .. name .. ']]</span>' | |||
return {t = t, s = mw.ustring.lower(name)} | |||
end | |||
local function stat(rec, k) | |||
return (rec.stats or {})[k] | |||
end | |||
local function nstat(rec, k) | |||
return Core.numStat(stat(rec, k)) | |||
end | |||
local function totalDPS(rec) | |||
local s = rec.stats or {} | |||
local atk = Core.numStat(s['Attack Speed']) | |||
if atk == nil then return nil end | |||
local total = Core.numStat(s['Damage']) or 0 | |||
for _, k in ipairs({'Holy Damage', 'Fire Damage', 'Electric Damage'}) do | |||
local v = Core.numStat(s[k]) | |||
if v ~= nil and v > 0 and v < 1000 then total = total + v end | |||
end | |||
return Core.round1(total * atk) | |||
end | |||
local function wave10(rec, base, per) | |||
local b, pv = nstat(rec, base), nstat(rec, per) | |||
if b == nil or pv == nil then return nil end | |||
return Core.round1(b + pv * 9) | |||
end | |||
-- ----- dataset builders ---------------------------------------------- | |||
local function weaponRows(class) | |||
local headers = {THUMB, 'Name', 'Tier', 'Price', 'Damage', | |||
swatch('Holy', '--dmg-holy', '#e8c34a', 'Holy Damage'), | |||
swatch('Fire', '--dmg-fire', '#ff6b35', 'Fire Damage'), | |||
swatch('Electric', '--dmg-electric', '#4ea3ff', 'Electric Damage'), | |||
'DPS', 'Atk Speed', 'Crit', 'Range', 'Pierce', 'Proj.', 'Bounce'} | |||
local rows = {} | |||
for _, rec in ipairs(Core.load('Weapons')) do | |||
if Core.weaponClass(rec) == class then | |||
-- Only Pierce/Bounce use the +/-1000 on/off sentinel (numcell); | |||
-- every other column is a real number (Range 1400 is a real range, | |||
-- not "unlimited"), so use the sentinel-free plaincell. | |||
local cells = { | |||
iconcell(rec), | |||
namecell(rec), | |||
plaincell(rec.tier), | |||
plaincell(rec.price), | |||
plaincell(stat(rec, 'Damage')), | |||
chipcell('Holy', stat(rec, 'Holy Damage')), | |||
chipcell('Fire', stat(rec, 'Fire Damage')), | |||
chipcell('Electric', stat(rec, 'Electric Damage')), | |||
plaincell(totalDPS(rec)), | |||
plaincell(stat(rec, 'Attack Speed')), | |||
plaincell(stat(rec, 'Critical Chance')), | |||
plaincell(stat(rec, 'Range')), | |||
numcell(stat(rec, 'Pierce')), | |||
plaincell(stat(rec, 'Projectiles')), | |||
numcell(stat(rec, 'Bounce')), | |||
} | |||
local elements = {} | |||
for _, e in ipairs({{'holy', 'Holy Damage'}, {'fire', 'Fire Damage'}, | |||
{'electric', 'Electric Damage'}}) do | |||
local v = nstat(rec, e[2]) | |||
if v ~= nil and v > 0 and v < 1000 then elements[#elements + 1] = e[1] end | |||
end | |||
local attrs = ' data-element="' .. (#elements > 0 and table.concat(elements, ' ') or 'none') | |||
.. '" data-class="' .. class:lower() | |||
.. '" data-tier="' .. tostring(rec.tier or '') .. '"' | |||
rows[#rows + 1] = {a = attrs, c = cells} | |||
end | |||
end | |||
-- DPS descending (cell 9 after the Thumbnail/Name split; stable: | |||
-- name asc tiebreak via the Name cell sortkey, cell 2). | |||
table.sort(rows, function(x, y) | |||
local dx, dy = x.c[9].s or 0, y.c[9].s or 0 | |||
if dx ~= dy then return dx > dy end | |||
return (x.c[2].s or '') < (y.c[2].s or '') | |||
end) | |||
return headers, rows | |||
end | |||
local function enemyRows() | |||
local headers = {THUMB, 'Name', 'Health', 'HP/Wave', 'HP @ W10', 'Damage', | |||
'Dmg/Wave', 'Dmg @ W10', 'Speed', 'Resources'} | |||
local rows = {} | |||
for _, rec in ipairs(Core.sorted('Enemies')) do | |||
local keys = {} | |||
for _, k in ipairs(rec.tags or {}) do keys[#keys + 1] = k:lower() end | |||
rows[#rows + 1] = { | |||
a = ' data-type="' .. (#keys > 0 and table.concat(keys, ' ') or 'other') .. '"', | |||
c = { | |||
iconcell(rec), | |||
namecell(rec), | |||
plaincell(stat(rec, 'Health')), | |||
plaincell(stat(rec, 'HP per Wave')), | |||
plaincell(wave10(rec, 'Health', 'HP per Wave')), | |||
plaincell(stat(rec, 'Damage')), | |||
plaincell(stat(rec, 'Damage per Wave')), | |||
plaincell(wave10(rec, 'Damage', 'Damage per Wave')), | |||
plaincell(stat(rec, 'Speed')), | |||
plaincell(stat(rec, 'Resources')), | |||
}, | |||
} | |||
end | |||
return headers, rows | |||
end | |||
local function characterRows() | |||
local headers = {THUMB, 'Name', 'Health Points', 'Damage', 'Critical Chance', | |||
'Attack Speed', 'Range', 'Speed', 'Armor', 'Dodge'} | |||
local rows = {} | |||
for _, rec in ipairs(Core.sorted('Characters')) do | |||
rows[#rows + 1] = {a = '', c = { | |||
iconcell(rec), | |||
namecell(rec), | |||
plaincell(stat(rec, 'Health Points')), | |||
plaincell(stat(rec, 'Damage')), | |||
plaincell(stat(rec, 'Critical Chance')), | |||
plaincell(stat(rec, 'Attack Speed')), | |||
plaincell(stat(rec, 'Range')), | |||
plaincell(stat(rec, 'Speed')), | |||
plaincell(stat(rec, 'Armor')), | |||
plaincell(stat(rec, 'Dodge')), | |||
}} | |||
end | |||
return headers, rows | |||
end | |||
local DATASETS = { | |||
Weapons_Melee = function() return weaponRows('Melee') end, | |||
Weapons_Ranged = function() return weaponRows('Ranged') end, | |||
Enemies = enemyRows, | |||
Characters = characterRows, | |||
} | |||
-- ----- renderer -------------------------------------------------------- | |||
local function cell(c) | local function cell(c) | ||
local t = tostring(c.t or '—') | local t = tostring(c.t or '—') | ||
if c.s ~= nil then | if c.s ~= nil then | ||
return '| data-sort-value="' .. tostring( | local s = c.s | ||
if type(s) == 'number' then s = Core.fmtNum(s) end | |||
return '| data-sort-value="' .. tostring(s) .. '" | ' .. t | |||
end | end | ||
return '| ' .. t | return '| ' .. t | ||
| Line 26: | Line 306: | ||
end | end | ||
ds = ds:gsub('^%s+', ''):gsub('%s+$', '') | ds = ds:gsub('^%s+', ''):gsub('%s+$', '') | ||
local | local build = DATASETS[ds] | ||
if not | if not build then | ||
return '<strong class="error">Module:Compare: | return '<strong class="error">Module:Compare: unknown dataset ' | ||
.. ds .. ' | .. mw.text.nowiki(ds) .. '</strong>' | ||
end | end | ||
local headers, rows = build() | |||
local out = { '{| class="wikitable sortable" style="' .. TBL_STYLE .. '"' } | local out = { '{| class="wikitable sortable" style="' .. TBL_STYLE .. '"' } | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
for _, h in ipairs( | for _, h in ipairs(headers) do | ||
out[#out+1] = '! style="' .. TH_STYLE .. '" | ' .. tostring( | local label, attrs = h, '' | ||
if type(h) == 'table' then | |||
label = h[1] | |||
if h.unsortable then attrs = ' class="unsortable"' end | |||
end | |||
out[#out+1] = '!' .. attrs .. ' style="' .. TH_STYLE .. '" | ' | |||
.. headerLink(tostring(label)) | |||
end | end | ||
for _, row in ipairs( | for _, row in ipairs(rows) do | ||
out[#out+1] = '|-' .. tostring(row.a or '') | out[#out+1] = '|-' .. tostring(row.a or '') | ||
for _, c in ipairs( | for _, c in ipairs(row.c) do | ||
out[#out+1] = cell(c) | out[#out+1] = cell(c) | ||
end | end | ||
Latest revision as of 02:49, 11 June 2026
Documentation for this module may be created at Module:Compare/doc
-- Module:Compare — sortable side-by-side comparison tables.
--
-- WHAT IT DOES
-- Builds the big comparison tables (used on the Weapons index, the
-- Enemies index, and the Weapon comparison / Hero comparison pages;
-- 'Enemy comparison' is a redirect to Enemies since 2026-06-10f),
-- including the COMPUTED columns that are not stored anywhere:
-- * Weapon DPS = (Damage + Holy + Fire + Electric) x Attack Speed,
-- rounded to 1 decimal.
-- * Enemy wave-10 projections = base + per-wave x 9.
--
-- HOW TO INVOKE
-- {{#invoke:Compare|render|Weapons_Melee}} melee weapons, DPS desc
-- {{#invoke:Compare|render|Weapons_Ranged}} ranged weapons, DPS desc
-- {{#invoke:Compare|render|Enemies}} base + per-wave + wave-10
-- {{#invoke:Compare|render|Characters}} hero stat modifiers
--
-- SOURCE DATA IT READS (via Module:Core)
-- Data:Weapons.json, Data:Enemies.json, Data:Characters.json
--
-- EDITING NOTES
-- * Every table opens with a Thumbnail column (icon only, header marked
-- class="unsortable") followed by a sortable linked Name column.
-- * Stat sentinels apply ONLY to the weapon Pierce/Bounce columns
-- (numcell): 1000 -> infinity sign (unlimited), <= -1000 -> em-dash.
-- Every other column (incl. Range, enemy HP/Wave, hero stats) uses
-- plaincell and shows the real number, however large.
-- * Each row carries data-element / data-class / data-tier / data-type
-- attributes — MediaWiki:Common.js uses them for the filter chips
-- above the tables. Removing them breaks the chip filtering.
-- * To add a column, add a header in the relevant *Rows() builder and
-- a matching cell in the same position.
local Core = require('Module:Core')
local p = {}
local TBL_STYLE = 'font-size:0.92em;background:var(--table-row-odd, #1b1c20);color:var(--table-text, #e6e6e6);border-color:var(--table-border, #3a3c44);'
local TH_STYLE = 'background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);'
-- Colored element swatch for column headers. When the damage-type hub
-- page exists (existence-checked via Core.statPageTarget) the label links
-- to it; an inner span keeps the element color inside the anchor.
local function swatch(label, tok, hexv, statName)
local inner = label
local target = statName and Core.statPageTarget(statName)
if target then
inner = '[[' .. target .. '|<span style="color:var(' .. tok .. ', '
.. hexv .. ');font-weight:bold;">' .. label .. '</span>]]'
end
return '<span style="color:var(' .. tok .. ', ' .. hexv
.. ');font-weight:bold;white-space:nowrap;">● ' .. inner .. '</span>'
end
-- Abbreviated column headers -> the stat key whose page they should link
-- to. Full-name headers resolve via Core.statPageTarget directly.
local HDR_STAT = {
['Atk Speed'] = 'Attack Speed',
['Crit'] = 'Critical Chance',
['Proj.'] = 'Projectiles',
}
-- Link a plain-text column header to its stat reference page when one
-- exists; non-stat headers (and already-HTML headers like the swatches)
-- pass through untouched. Page-less stats stay plain — never a redlink.
local function headerLink(h)
if h:find('[<%[]') then return h end
local target = Core.statPageTarget(HDR_STAT[h] or h)
if not target then return h end
if target == h then return '[[' .. h .. ']]' end
return '[[' .. target .. '|' .. h .. ']]'
end
-- Coerce a raw stat value to {display, sortval}: '15%' keeps the %
-- sign for display but sorts by the number 15.
local function num(v)
if v == nil then return '—', nil end
if type(v) == 'string' then
local s = v:gsub('^%s+', ''):gsub('%s+$', '')
local n = tonumber(s) or tonumber(s:match('^([%-%d%.]+)%%$'))
if n == nil then return s, nil end
if s:sub(-1) == '%' then return s, n end
v = n
end
if type(v) == 'number' then
if v >= 1000 then return '∞', 1e9 end
if v <= -1000 then return '—', -1 end
return Core.fmtNum(v), v
end
return tostring(v), nil
end
local function numcell(v)
local t, s = num(v)
return {t = t, s = s}
end
-- Sentinel-FREE number coercion. The >=1000 / <=-1000 sentinels in num()
-- are a WEAPON convention (Pierce 1000 = unlimited, Bounce -1000 = hidden);
-- enemy/character stats use real large numbers (e.g. a boss's HP/Wave of
-- 5000), so those tables must format plainly or 5000 wrongly renders as inf.
local function plain(v)
if v == nil then return '—', nil end
if type(v) == 'string' then
local s = v:gsub('^%s+', ''):gsub('%s+$', '')
local n = tonumber(s) or tonumber(s:match('^([%-%d%.]+)%%$'))
if n == nil then return s, nil end
if s:sub(-1) == '%' then return s, n end
v = n
end
if type(v) == 'number' then return Core.fmtNum(v), v end
return tostring(v), nil
end
local function plaincell(v)
local t, s = plain(v)
return {t = t, s = s}
end
local DMG = {
Holy = {'--dmg-holy', '#e8c34a', 'wm-dmg-holy'},
Fire = {'--dmg-fire', '#ff6b35', 'wm-dmg-fire'},
Electric = {'--dmg-electric', '#4ea3ff', 'wm-dmg-electric'},
}
local function chipcell(dmg, v)
local t, s = num(v)
if s == nil or s <= 0 or t == '—' then return {t = '—', s = 0} end
local c = DMG[dmg]
return {t = '<span class="' .. c[3] .. '" style="color:var(' .. c[1] .. ', '
.. c[2] .. ');font-weight:bold;">' .. t .. '</span>', s = s}
end
-- Header marker for the icon-only Thumbnail column: the renderer emits
-- class="unsortable" on this th so the sort arrows skip it.
local THUMB = {'Thumbnail', unsortable = true}
-- Icon-only Thumbnail cell (links to the entity page; em-dash when the
-- record has no icon). 48px matches the category-index thumbnail size.
local function iconcell(rec)
if rec.icon and rec.icon ~= '' then
local slug = rec.slug or rec.name or ''
return {t = '[[File:' .. rec.icon .. '|48px|link=' .. slug
.. '|alt=' .. (rec.name or slug) .. ']]'}
end
return {t = '—'}
end
-- Linked-name cell, sortable by lowercased name, wrapped in .wm-tip
-- (Common.js hover-infobox popup).
local function namecell(rec)
local slug = rec.slug or rec.name or ''
local name = rec.name or slug
local t = '<span class="wm-tip" data-tip-title="' .. slug .. '">[['
.. slug .. '|' .. name .. ']]</span>'
return {t = t, s = mw.ustring.lower(name)}
end
local function stat(rec, k)
return (rec.stats or {})[k]
end
local function nstat(rec, k)
return Core.numStat(stat(rec, k))
end
local function totalDPS(rec)
local s = rec.stats or {}
local atk = Core.numStat(s['Attack Speed'])
if atk == nil then return nil end
local total = Core.numStat(s['Damage']) or 0
for _, k in ipairs({'Holy Damage', 'Fire Damage', 'Electric Damage'}) do
local v = Core.numStat(s[k])
if v ~= nil and v > 0 and v < 1000 then total = total + v end
end
return Core.round1(total * atk)
end
local function wave10(rec, base, per)
local b, pv = nstat(rec, base), nstat(rec, per)
if b == nil or pv == nil then return nil end
return Core.round1(b + pv * 9)
end
-- ----- dataset builders ----------------------------------------------
local function weaponRows(class)
local headers = {THUMB, 'Name', 'Tier', 'Price', 'Damage',
swatch('Holy', '--dmg-holy', '#e8c34a', 'Holy Damage'),
swatch('Fire', '--dmg-fire', '#ff6b35', 'Fire Damage'),
swatch('Electric', '--dmg-electric', '#4ea3ff', 'Electric Damage'),
'DPS', 'Atk Speed', 'Crit', 'Range', 'Pierce', 'Proj.', 'Bounce'}
local rows = {}
for _, rec in ipairs(Core.load('Weapons')) do
if Core.weaponClass(rec) == class then
-- Only Pierce/Bounce use the +/-1000 on/off sentinel (numcell);
-- every other column is a real number (Range 1400 is a real range,
-- not "unlimited"), so use the sentinel-free plaincell.
local cells = {
iconcell(rec),
namecell(rec),
plaincell(rec.tier),
plaincell(rec.price),
plaincell(stat(rec, 'Damage')),
chipcell('Holy', stat(rec, 'Holy Damage')),
chipcell('Fire', stat(rec, 'Fire Damage')),
chipcell('Electric', stat(rec, 'Electric Damage')),
plaincell(totalDPS(rec)),
plaincell(stat(rec, 'Attack Speed')),
plaincell(stat(rec, 'Critical Chance')),
plaincell(stat(rec, 'Range')),
numcell(stat(rec, 'Pierce')),
plaincell(stat(rec, 'Projectiles')),
numcell(stat(rec, 'Bounce')),
}
local elements = {}
for _, e in ipairs({{'holy', 'Holy Damage'}, {'fire', 'Fire Damage'},
{'electric', 'Electric Damage'}}) do
local v = nstat(rec, e[2])
if v ~= nil and v > 0 and v < 1000 then elements[#elements + 1] = e[1] end
end
local attrs = ' data-element="' .. (#elements > 0 and table.concat(elements, ' ') or 'none')
.. '" data-class="' .. class:lower()
.. '" data-tier="' .. tostring(rec.tier or '') .. '"'
rows[#rows + 1] = {a = attrs, c = cells}
end
end
-- DPS descending (cell 9 after the Thumbnail/Name split; stable:
-- name asc tiebreak via the Name cell sortkey, cell 2).
table.sort(rows, function(x, y)
local dx, dy = x.c[9].s or 0, y.c[9].s or 0
if dx ~= dy then return dx > dy end
return (x.c[2].s or '') < (y.c[2].s or '')
end)
return headers, rows
end
local function enemyRows()
local headers = {THUMB, 'Name', 'Health', 'HP/Wave', 'HP @ W10', 'Damage',
'Dmg/Wave', 'Dmg @ W10', 'Speed', 'Resources'}
local rows = {}
for _, rec in ipairs(Core.sorted('Enemies')) do
local keys = {}
for _, k in ipairs(rec.tags or {}) do keys[#keys + 1] = k:lower() end
rows[#rows + 1] = {
a = ' data-type="' .. (#keys > 0 and table.concat(keys, ' ') or 'other') .. '"',
c = {
iconcell(rec),
namecell(rec),
plaincell(stat(rec, 'Health')),
plaincell(stat(rec, 'HP per Wave')),
plaincell(wave10(rec, 'Health', 'HP per Wave')),
plaincell(stat(rec, 'Damage')),
plaincell(stat(rec, 'Damage per Wave')),
plaincell(wave10(rec, 'Damage', 'Damage per Wave')),
plaincell(stat(rec, 'Speed')),
plaincell(stat(rec, 'Resources')),
},
}
end
return headers, rows
end
local function characterRows()
local headers = {THUMB, 'Name', 'Health Points', 'Damage', 'Critical Chance',
'Attack Speed', 'Range', 'Speed', 'Armor', 'Dodge'}
local rows = {}
for _, rec in ipairs(Core.sorted('Characters')) do
rows[#rows + 1] = {a = '', c = {
iconcell(rec),
namecell(rec),
plaincell(stat(rec, 'Health Points')),
plaincell(stat(rec, 'Damage')),
plaincell(stat(rec, 'Critical Chance')),
plaincell(stat(rec, 'Attack Speed')),
plaincell(stat(rec, 'Range')),
plaincell(stat(rec, 'Speed')),
plaincell(stat(rec, 'Armor')),
plaincell(stat(rec, 'Dodge')),
}}
end
return headers, rows
end
local DATASETS = {
Weapons_Melee = function() return weaponRows('Melee') end,
Weapons_Ranged = function() return weaponRows('Ranged') end,
Enemies = enemyRows,
Characters = characterRows,
}
-- ----- renderer --------------------------------------------------------
local function cell(c)
local t = tostring(c.t or '—')
if c.s ~= nil then
local s = c.s
if type(s) == 'number' then s = Core.fmtNum(s) end
return '| data-sort-value="' .. tostring(s) .. '" | ' .. t
end
return '| ' .. t
end
function p.render(frame)
local ds = frame.args[1] or frame.args.cat
if not ds or ds == '' then
return '<strong class="error">Module:Compare: missing dataset arg</strong>'
end
ds = ds:gsub('^%s+', ''):gsub('%s+$', '')
local build = DATASETS[ds]
if not build then
return '<strong class="error">Module:Compare: unknown dataset '
.. mw.text.nowiki(ds) .. '</strong>'
end
local headers, rows = build()
local out = { '{| class="wikitable sortable" style="' .. TBL_STYLE .. '"' }
out[#out+1] = '|-'
for _, h in ipairs(headers) do
local label, attrs = h, ''
if type(h) == 'table' then
label = h[1]
if h.unsortable then attrs = ' class="unsortable"' end
end
out[#out+1] = '!' .. attrs .. ' style="' .. TH_STYLE .. '" | '
.. headerLink(tostring(label))
end
for _, row in ipairs(rows) do
out[#out+1] = '|-' .. tostring(row.a or '')
for _, c in ipairs(row.c) do
out[#out+1] = cell(c)
end
end
out[#out+1] = '|}'
return table.concat(out, '\n')
end
return p