From Arms of God Wiki

bot: render-time derivation refactor (phase 1)
docs: clarify sentinel scoping comment
 
(5 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:Compare — comparison-table renderer (Arms of God).
-- Module:Compare — sortable side-by-side comparison tables.
-- RENDER-TIME DERIVATION: computes every dataset from the source
-- Data:<Category>.json pages via Module:Core. No Data:Compare_* pages.
--
--
--  {{#invoke:Compare|render|Weapons_Melee}}   — melee weapons, DPS desc
-- WHAT IT DOES
--  {{#invoke:Compare|render|Weapons_Ranged}}  — ranged weapons, DPS desc
--  Builds the big comparison tables (used on the Weapons index, the
--  {{#invoke:Compare|render|Enemies}}        — base + per-wave + wave-10
--   Enemies index, and the Weapon comparison / Hero comparison pages;
--  {{#invoke:Compare|render|Characters}}      — hero stat modifiers
--  '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.
--
--
-- Weapon DPS = (Damage + Holy + Fire + Electric) × Attack Speed, rounded
-- HOW TO INVOKE
-- to 1 decimal. Sentinels: value >= 1000 renders '∞' (sorts top);
--  {{#invoke:Compare|render|Weapons_Melee}}  melee weapons, DPS desc
-- value <= -1000 renders '—' (sorts bottom). Rows carry data-element /
--  {{#invoke:Compare|render|Weapons_Ranged}}  ranged weapons, DPS desc
-- data-class / data-tier / data-type attributes for the Common.js chips.
--  {{#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 Core = require('Module:Core')
local p = {}
local p = {}
Line 18: Line 37:
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);'


local function swatch(label, tok, hexv)
-- 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
   return '<span style="color:var(' .. tok .. ', ' .. hexv
     .. ');font-weight:bold;white-space:nowrap;">&#9679; ' .. label .. '</span>'
     .. ');font-weight:bold;white-space:nowrap;">&#9679; ' .. 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
end


-- Coerce a raw stat value to {display, sortval}. Mirrors the historic
-- Coerce a raw stat value to {display, sortval}: '15%' keeps the %
-- emit-time _num(): '15%' keeps the % display but sorts numerically.
-- sign for display but sorts by the number 15.
local function num(v)
local function num(v)
   if v == nil then return '—', nil end
   if v == nil then return '—', nil end
Line 44: Line 91:
local function numcell(v)
local function numcell(v)
   local t, s = num(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}
   return {t = t, s = s}
end
end
Line 61: Line 130:
end
end


local function iconlinkcell(rec)
-- 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 slug = rec.slug or rec.name or ''
   local name = rec.name or slug
   local name = rec.name or slug
   local t
   local t = '<span class="wm-tip" data-tip-title="' .. slug .. '">[['
  if rec.icon and rec.icon ~= '' then
    .. slug .. '|' .. name .. ']]</span>'
    t = '[[File:' .. rec.icon .. '|24px|link=' .. slug .. '|alt=' .. name
      .. ']] [[' .. slug .. '|' .. name .. ']]'
  else
    t = '[[' .. slug .. '|' .. name .. ']]'
  end
  t = '<span class="wm-tip" data-tip-title="' .. slug .. '">' .. t .. '</span>'
   return {t = t, s = mw.ustring.lower(name)}
   return {t = t, s = mw.ustring.lower(name)}
end
end
Line 104: Line 184:


local function weaponRows(class)
local function weaponRows(class)
   local headers = {'Weapon', 'Tier', 'Price', 'Damage',
   local headers = {THUMB, 'Name', 'Tier', 'Price', 'Damage',
     swatch('Holy', '--dmg-holy', '#e8c34a'),
     swatch('Holy', '--dmg-holy', '#e8c34a', 'Holy Damage'),
     swatch('Fire', '--dmg-fire', '#ff6b35'),
     swatch('Fire', '--dmg-fire', '#ff6b35', 'Fire Damage'),
     swatch('Electric', '--dmg-electric', '#4ea3ff'),
     swatch('Electric', '--dmg-electric', '#4ea3ff', 'Electric Damage'),
     'DPS', 'Atk Speed', 'Crit', 'Range', 'Pierce', 'Proj.', 'Bounce'}
     'DPS', 'Atk Speed', 'Crit', 'Range', 'Pierce', 'Proj.', 'Bounce'}
   local rows = {}
   local rows = {}
   for _, rec in ipairs(Core.load('Weapons')) do
   for _, rec in ipairs(Core.load('Weapons')) do
     if Core.weaponClass(rec) == class then
     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 = {
       local cells = {
         iconlinkcell(rec),
         iconcell(rec),
         numcell(rec.tier),
         namecell(rec),
         numcell(rec.price),
        plaincell(rec.tier),
         numcell(stat(rec, 'Damage')),
         plaincell(rec.price),
         plaincell(stat(rec, 'Damage')),
         chipcell('Holy', stat(rec, 'Holy Damage')),
         chipcell('Holy', stat(rec, 'Holy Damage')),
         chipcell('Fire', stat(rec, 'Fire Damage')),
         chipcell('Fire', stat(rec, 'Fire Damage')),
         chipcell('Electric', stat(rec, 'Electric Damage')),
         chipcell('Electric', stat(rec, 'Electric Damage')),
         numcell(totalDPS(rec)),
         plaincell(totalDPS(rec)),
         numcell(stat(rec, 'Attack Speed')),
         plaincell(stat(rec, 'Attack Speed')),
         numcell(stat(rec, 'Critical Chance')),
         plaincell(stat(rec, 'Critical Chance')),
         numcell(stat(rec, 'Range')),
         plaincell(stat(rec, 'Range')),
         numcell(stat(rec, 'Pierce')),
         numcell(stat(rec, 'Pierce')),
         numcell(stat(rec, 'Projectiles')),
         plaincell(stat(rec, 'Projectiles')),
         numcell(stat(rec, 'Bounce')),
         numcell(stat(rec, 'Bounce')),
       }
       }
Line 140: Line 224:
     end
     end
   end
   end
   -- DPS descending (stable: name asc tiebreak via the first cell sortkey).
   -- 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)
   table.sort(rows, function(x, y)
     local dx, dy = x.c[8].s or 0, y.c[8].s or 0
     local dx, dy = x.c[9].s or 0, y.c[9].s or 0
     if dx ~= dy then return dx > dy end
     if dx ~= dy then return dx > dy end
     return (x.c[1].s or '') < (y.c[1].s or '')
     return (x.c[2].s or '') < (y.c[2].s or '')
   end)
   end)
   return headers, rows
   return headers, rows
Line 150: Line 235:


local function enemyRows()
local function enemyRows()
   local headers = {'Enemy', 'Health', 'HP/Wave', 'HP @ W10', 'Damage',
   local headers = {THUMB, 'Name', 'Health', 'HP/Wave', 'HP @ W10', 'Damage',
                   'Dmg/Wave', 'Dmg @ W10', 'Speed', 'Resources'}
                   'Dmg/Wave', 'Dmg @ W10', 'Speed', 'Resources'}
   local rows = {}
   local rows = {}
Line 159: Line 244:
       a = ' data-type="' .. (#keys > 0 and table.concat(keys, ' ') or 'other') .. '"',
       a = ' data-type="' .. (#keys > 0 and table.concat(keys, ' ') or 'other') .. '"',
       c = {
       c = {
         iconlinkcell(rec),
         iconcell(rec),
         numcell(stat(rec, 'Health')),
         namecell(rec),
         numcell(stat(rec, 'HP per Wave')),
        plaincell(stat(rec, 'Health')),
         numcell(wave10(rec, 'Health', 'HP per Wave')),
         plaincell(stat(rec, 'HP per Wave')),
         numcell(stat(rec, 'Damage')),
         plaincell(wave10(rec, 'Health', 'HP per Wave')),
         numcell(stat(rec, 'Damage per Wave')),
         plaincell(stat(rec, 'Damage')),
         numcell(wave10(rec, 'Damage', 'Damage per Wave')),
         plaincell(stat(rec, 'Damage per Wave')),
         numcell(stat(rec, 'Speed')),
         plaincell(wave10(rec, 'Damage', 'Damage per Wave')),
         numcell(stat(rec, 'Resources')),
         plaincell(stat(rec, 'Speed')),
         plaincell(stat(rec, 'Resources')),
       },
       },
     }
     }
Line 175: Line 261:


local function characterRows()
local function characterRows()
   local headers = {'Hero', 'Health Points', 'Damage', 'Critical Chance',
   local headers = {THUMB, 'Name', 'Health Points', 'Damage', 'Critical Chance',
                   'Attack Speed', 'Range', 'Speed', 'Armor', 'Dodge'}
                   'Attack Speed', 'Range', 'Speed', 'Armor', 'Dodge'}
   local rows = {}
   local rows = {}
   for _, rec in ipairs(Core.sorted('Characters')) do
   for _, rec in ipairs(Core.sorted('Characters')) do
     rows[#rows + 1] = {a = '', c = {
     rows[#rows + 1] = {a = '', c = {
       iconlinkcell(rec),
       iconcell(rec),
       numcell(stat(rec, 'Health Points')),
       namecell(rec),
       numcell(stat(rec, 'Damage')),
      plaincell(stat(rec, 'Health Points')),
       numcell(stat(rec, 'Critical Chance')),
       plaincell(stat(rec, 'Damage')),
       numcell(stat(rec, 'Attack Speed')),
       plaincell(stat(rec, 'Critical Chance')),
       numcell(stat(rec, 'Range')),
       plaincell(stat(rec, 'Attack Speed')),
       numcell(stat(rec, 'Speed')),
       plaincell(stat(rec, 'Range')),
       numcell(stat(rec, 'Armor')),
       plaincell(stat(rec, 'Speed')),
       numcell(stat(rec, 'Dodge')),
       plaincell(stat(rec, 'Armor')),
       plaincell(stat(rec, 'Dodge')),
     }}
     }}
   end
   end
Line 228: Line 315:
   out[#out+1] = '|-'
   out[#out+1] = '|-'
   for _, h in ipairs(headers) do
   for _, h in ipairs(headers) do
     out[#out+1] = '! style="' .. TH_STYLE .. '" | ' .. tostring(h)
    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(rows) do
   for _, row in ipairs(rows) do

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;">&#9679; ' .. 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