From Arms of God Wiki

Revision as of 02:49, 11 June 2026 by Ta1ha (talk | contribs) (docs: clarify sentinel scoping comment)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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