From Arms of God Wiki

bot: render-time derivation refactor (phase 1)
bot: documentation pass — editor-facing docs (data descriptions, module comments, template usage, Help rewrite)
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,
--  {{#invoke:Compare|render|Enemies}}        — base + per-wave + wave-10
--   Weapon comparison, Enemy comparison and Hero comparison pages),
--  {{#invoke:Compare|render|Characters}}      — hero stat modifiers
--  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
--   * 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 Core = require('Module:Core')
local p = {}
local p = {}
Line 23: Line 37:
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

Revision as of 16:04, 10 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,
--   Weapon comparison, Enemy comparison and Hero comparison pages),
--   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
--   * 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);'

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

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

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

local function enemyRows()
  local headers = {'Enemy', '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 = {
        iconlinkcell(rec),
        numcell(stat(rec, 'Health')),
        numcell(stat(rec, 'HP per Wave')),
        numcell(wave10(rec, 'Health', 'HP per Wave')),
        numcell(stat(rec, 'Damage')),
        numcell(stat(rec, 'Damage per Wave')),
        numcell(wave10(rec, 'Damage', 'Damage per Wave')),
        numcell(stat(rec, 'Speed')),
        numcell(stat(rec, 'Resources')),
      },
    }
  end
  return headers, rows
end

local function characterRows()
  local headers = {'Hero', '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 = {
      iconlinkcell(rec),
      numcell(stat(rec, 'Health Points')),
      numcell(stat(rec, 'Damage')),
      numcell(stat(rec, 'Critical Chance')),
      numcell(stat(rec, 'Attack Speed')),
      numcell(stat(rec, 'Range')),
      numcell(stat(rec, 'Speed')),
      numcell(stat(rec, 'Armor')),
      numcell(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
    out[#out+1] = '! style="' .. TH_STYLE .. '" | ' .. tostring(h)
  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