From Arms of God Wiki

fix: enemy/character comparison stats must not use the weapon >=1000 inf sentinel (e.g. Azrael HP/Wave 5000)
fix: scope the +/-1000 Unlimited/disabled sentinel to Pierce/Bounce only (weapon Range 1400, hero/enemy large stats are real numbers)
Line 190: Line 190:
   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 = {
         iconcell(rec),
         iconcell(rec),
         namecell(rec),
         namecell(rec),
         numcell(rec.tier),
         plaincell(rec.tier),
         numcell(rec.price),
         plaincell(rec.price),
         numcell(stat(rec, 'Damage')),
         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')),
       }
       }

Revision as of 02:47, 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: a value >= 1000 renders as the infinity sign
--     (sorts to the top); <= -1000 renders as an em-dash (sorts bottom).
--   * 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