From Arms of God Wiki

Revision as of 02:47, 11 June 2026 by Ta1ha (talk | contribs) (fix: scope the +/-1000 Unlimited/disabled sentinel to Pierce/Bounce only (weapon Range 1400, hero/enemy large stats are real numbers))
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Documentation for this module may be created at Module:Core/doc

-- Module:Core — the engine room of this wiki.
--
-- WHAT IT DOES
--   Every infobox value, index table, badge, tag link, sort order and
--   lore join on this wiki is computed here (or in a module that
--   require()s this one) at render time, from the ten source
--   Data:<Category>.json pages. There are no precomputed data pages:
--   edit a value on a Data page, purge, and every view of it updates.
--
-- HOW TO INVOKE
--   You normally don't invoke Core directly. Each category module
--   (Module:Weapons, Module:Enemies, ...) is a thin shim that binds a
--   category name and forwards to the entry points at the bottom of
--   this file:
--     {{#invoke:Weapons|infobox|id=Cutter}}     -> p.infoboxEntry
--     {{#invoke:Weapons|body|id=Cutter}}        -> p.bodyEntry
--     {{#invoke:Weapons|index}}                 -> p.indexEntry
--     {{#invoke:Weapons|render|id=Cutter}}      -> p.renderEntry (all-in-one)
--
-- SOURCE DATA IT READS (see each page's own `description` for fields)
--   Data:Weapons.json, Data:Characters.json, Data:Blessings.json,
--   Data:Upgrades.json, Data:Crux.json, Data:Passives.json,
--   Data:Enemies.json, Data:Achievements.json, Data:Codex.json,
--   Data:Tags.json
--   Common record fields: id, slug, name, icon, tier, price, locked,
--   type, state, tags[] (keys into Data:Tags.json), stats{}, description.
--
-- EDITING NOTES
--   * `slug` IS the page title. Never rebuild it from `name` in Lua —
--     always read rec.slug.
--   * Presentation config (stat display order, glyphs, infobox field
--     lists, index columns, sort keys) lives in the tables below and is
--     editable on-wiki in this one place.
--   * Cross-category functions (find, tagMembership, codexFor) load
--     several Data pages in one parse; the host runs Scribunto with a
--     raised (256 MiB) memory cap to allow this.
--   * See Help:Wiki Editing for the full architecture guide.
local p = {}

-- ===================================================================
-- registry
-- ===================================================================
p.CATEGORIES = {
  {name = 'Weapons',      singular = 'Weapon'},
  {name = 'Characters',   singular = 'Character'},
  {name = 'Blessings',    singular = 'Blessing'},
  {name = 'Upgrades',     singular = 'Upgrade'},
  {name = 'Crux',         singular = 'Crux'},
  {name = 'Passives',     singular = 'Passive'},
  {name = 'Enemies',      singular = 'Enemy'},
  {name = 'Achievements', singular = 'Achievement'},
  {name = 'Codex',        singular = 'Codex'},
  {name = 'Tags',         singular = 'Tag'},
}

local ALIAS = {}
for _, c in ipairs(p.CATEGORIES) do
  ALIAS[c.name:lower()] = c.name
  ALIAS[c.singular:lower()] = c.name
end

-- 'Weapon' / 'weapons' / 'Weapons' -> 'Weapons'; nil when not a category.
function p.resolveCategory(word)
  if not word or word == '' then return nil end
  return ALIAS[(word:gsub('^%s+', ''):gsub('%s+$', '')):lower()]
end

-- ===================================================================
-- data loading (memoized per parse)
-- ===================================================================

-- mw.loadJsonData returns a read-only table with quirky numeric keys;
-- copy it once into a plain mutable table so the rest of the module can
-- treat records as ordinary Lua tables.
local function fix(t)
  if type(t) ~= 'table' then return t end
  local out = {}
  for k, v in pairs(t) do
    out[k] = fix(v)
    if type(k) == 'number' then out[tostring(k)] = out[k] end
  end
  return out
end

local _data = {}
function p.load(cat)
  cat = p.resolveCategory(cat) or cat
  if _data[cat] == nil then
    local ok, d = pcall(mw.loadJsonData, 'Data:' .. cat .. '.json')
    if ok and type(d) == 'table' then
      _data[cat] = fix(d).records or {}
    else
      _data[cat] = {}
    end
  end
  return _data[cat]
end

local _byId = {}
function p.byId(cat)
  cat = p.resolveCategory(cat) or cat
  if _byId[cat] == nil then
    local m = {}
    for _, r in ipairs(p.load(cat)) do
      if r.id then m[r.id] = r end
      if r.slug then m[r.slug] = r end
    end
    _byId[cat] = m
  end
  return _byId[cat]
end

-- Iterate every category's records: returns a list of {cat=<name>, recs=<list>}.
function p.all()
  local out = {}
  for _, c in ipairs(p.CATEGORIES) do
    out[#out + 1] = {cat = c.name, recs = p.load(c.name)}
  end
  return out
end

-- First record across all categories matching slug or (lowercased) name.
-- Registry order wins on collisions (live entity beats its Codex entry).
function p.find(key)
  if not key or key == '' then return nil, nil end
  local k = mw.ustring.lower(key)
  for _, c in ipairs(p.CATEGORIES) do
    for _, r in ipairs(p.load(c.name)) do
      if r.slug == key or (r.slug and mw.ustring.lower(r.slug) == k)
        or (r.name and mw.ustring.lower(r.name) == k) then
        return c.name, r
      end
    end
  end
  return nil, nil
end

-- ===================================================================
-- small helpers
-- ===================================================================
function p.slugFor(rec) return rec and rec.slug or '' end
function p.iconFor(rec) return rec and rec.icon or '' end

function p.normName(s)
  s = mw.ustring.lower(s or '')
  return (mw.ustring.gsub(s, '[^%w]', ''))
end

function p.link(rec)
  if not rec then return '' end
  local slug = rec.slug or rec.name or ''
  return '[[' .. slug .. '|' .. (rec.name or slug) .. ']]'
end

-- icon + linked name wrapped in .wm-tip (Common.js hover-infobox popup).
function p.iconLink(rec, size)
  if not rec then return '' end
  local slug = rec.slug or rec.name or ''
  local name = rec.name or slug
  local tip = '<span class="wm-tip" data-tip-title="' .. slug .. '">'
  if rec.icon and rec.icon ~= '' then
    return tip .. '[[File:' .. rec.icon .. '|' .. (size or 24) .. 'px|link='
      .. slug .. ']] [[' .. slug .. '|' .. name .. ']]</span>'
  end
  return tip .. '[[' .. slug .. '|' .. name .. ']]</span>'
end

-- '56.1' / '6' (no trailing .0); non-numbers pass through as strings.
function p.fmtNum(v)
  if type(v) == 'number' then
    if v == math.floor(v) then return string.format('%d', v) end
    return tostring(v)
  end
  return tostring(v or '')
end

function p.round1(x)
  return math.floor(x * 10 + 0.5) / 10
end

-- Parse a stat value to a number: 11 -> 11, '15%' -> 15, else nil.
function p.numStat(v)
  if type(v) == 'number' then return v end
  if type(v) == 'string' then
    return tonumber(v) or tonumber(v:match('^%s*([%-%d%.]+)'))
  end
  return nil
end

-- Stats that use the +/-1000 on/off sentinel — a WEAPON convention only:
-- Pierce 1000 = "Unlimited", Pierce/Bounce -1000 = "disabled" (row dropped).
-- EVERY other stat is a real number even when large (a sniper's Range 1400,
-- a boss's HP/Wave 5000), so it must NEVER be sentinel-folded.
p.SENTINEL_STATS = { Pierce = true, Bounce = true }

-- Render one stat value. `signed` adds a '+' prefix for modifier-style
-- categories (heroes, blessings, upgrades, ...). `key` is the stat name;
-- the +/-1000 sentinel applies ONLY when key is a SENTINEL_STATS member.
function p.fmtStat(v, signed, key)
  if type(v) == 'number' then
    if p.SENTINEL_STATS[key] then
      if v >= 1000 then return 'Unlimited' end
      if v <= -1000 then return nil end
    end
    local s = p.fmtNum(v)
    if signed and v > 0 then s = '+' .. s end
    return s
  end
  local s = tostring(v or '')
  if signed and s ~= '' and s:sub(1, 1) ~= '-' and s:sub(1, 1) ~= '+' then
    s = '+' .. s
  end
  return s
end

function p.firstSentence(text, cap)
  local t = (text or ''):gsub('^%s+', '')
  t = t:match('^[^\n]*') or t
  local s = t:match('^(.-[%.!%?])%s') or t:match('^(.-[%.!%?])$') or t
  cap = cap or 140
  if mw.ustring.len(s) > cap then s = mw.ustring.sub(s, 1, cap) end
  return s
end

-- ===================================================================
-- stat presentation config
-- ===================================================================

-- Canonical display order for the stats block (JSON object key order is
-- not visible to Lua). Unknown stats append alphabetically at the end.
local STAT_ORDER = {
  'Damage', 'Damage per Wave', 'Health', 'HP per Wave',
  'Holy Damage', 'Fire Damage', 'Electric Damage',
  'Damage vs Burning', 'Damage vs Shocked', 'Melee Damage', 'Range Damage',
  'Attack Speed', 'Critical Chance', 'Critical Damage', 'Range',
  'Health Points', 'HP Rate', 'HP Delay',
  'Pierce', 'Projectiles', 'Bounce',
  'Explosive Chance', 'Explosive Radius', 'Explosive Range', 'HP Steal',
  'Armor', 'Dodge', 'Speed', 'Resources', 'Rarity Find',
  'Stagger', 'Stun', 'Freeze', 'XP Gain', 'Pickup Range', 'Cooldown',
}
local STAT_RANK = {}
for i, k in ipairs(STAT_ORDER) do STAT_RANK[k] = i end

-- Stat name -> in-game glyph file (18px next to each stat row).
local STAT_GLYPHS = {
  ['Damage'] = 'Stat_Damage.png',
  ['Fire Damage'] = 'Stat_Fire.png',
  ['Electric Damage'] = 'Stat_Electric.png',
  ['Holy Damage'] = 'Stat_Physical.png',
  ['Attack Speed'] = 'Stat_AttackSpeed.png',
  ['Critical Chance'] = 'Stat_Critical.png',
  ['Critical Damage'] = 'Stat_CriticalDmg.png',
  ['Range'] = 'Stat_Range.png',
  ['Pierce'] = 'Stat_Pierce.png',
  ['Bounce'] = 'Stat_Bounce.png',
  ['Projectiles'] = 'Stat_Projectiles.png',
  ['Stagger'] = 'Stat_Stagger.png',
  ['Stun'] = 'Stat_Stun.png',
  ['Freeze'] = 'Stat_Freeze.png',
  ['Explosive Chance'] = 'Stat_ExplosiveChance.png',
  ['Explosive Radius'] = 'Stat_ExplosiveRadius.png',
  ['Explosive Range'] = 'Stat_ExplosiveRadius.png',
  ['HP Steal'] = 'Stat_HPSteal.png',
  ['Health Points'] = 'Stat_HP.png',
  ['HP Rate'] = 'Stat_HPRate.png',
  ['HP Delay'] = 'Stat_HPDelay.png',
  ['Speed'] = 'Stat_Speed.png',
  ['Armor'] = 'Stat_Armor.png',
  ['Dodge'] = 'Stat_Dodge.png',
  ['Resources'] = 'Stat_Resources.png',
  ['Pickup Range'] = 'Stat_PickupRange.png',
  ['Rarity Find'] = 'Stat_RarityFind.png',
  ['XP Gain'] = 'Stat_XPGain.png',
  ['Cooldown'] = 'Stat_Cooldown.png',
  ['Damage vs Burning'] = 'Stat_VsFire.png',
  ['Damage vs Shocked'] = 'Stat_VsElectric.png',
  ['Health'] = 'Stat_HitPoints.png',
}
p.STAT_GLYPHS = STAT_GLYPHS

function p.statGlyph(stat)
  local f = STAT_GLYPHS[stat]
  if f then return '[[File:' .. f .. '|18px|link=]] ' end
  return ''
end

-- Elemental damage accent chips (dark-theme tokens with inline fallbacks).
p.DMG_CHIP = {
  ['Holy Damage']     = {'--dmg-holy', '#e8c34a', 'wm-dmg-holy'},
  ['Fire Damage']     = {'--dmg-fire', '#ff6b35', 'wm-dmg-fire'},
  ['Electric Damage'] = {'--dmg-electric', '#4ea3ff', 'wm-dmg-electric'},
}

-- ----- stat page links -------------------------------------------------
-- Per-stat reverse-lookup pages exist for every stat modified by at least
-- one hero / blessing / upgrade / passive / Crux power, and the codex Tips
-- pages double as hubs for the stats sharing their title (Holy / Fire /
-- Electric Damage, Critical Damage, Pierce, Bounce, Projectiles, Stagger,
-- Stun). A stat label is linkified ONLY when one of those pages exists, so
-- a page-less stat renders as plain text — never a redlink. Both sets are
-- computed from the source Data pages at render time, mirroring how the
-- page set itself is derived at emit time, so they match by construction.

-- Same alias fold as Module:StatIndex: the game data calls one mechanic
-- both 'Explosive Range' and 'Explosive Radius'; the page lives at
-- 'Explosive Radius'.
local STAT_PAGE_ALIAS = {['Explosive Range'] = 'Explosive Radius'}

-- Enemy-only scaling keys: never booster-modified, never get stat pages.
local NON_PAGE_STATS = {['Health'] = true, ['HP per Wave'] = true,
                        ['Damage per Wave'] = true}

local MODIFIER_CATS = {'Characters', 'Blessings', 'Upgrades', 'Passives', 'Crux'}

-- Set (key -> true) of stat keys modified by at least one booster record
-- (memoized per parse).
local _boosterKeys = nil
function p.boosterStatKeys()
  if _boosterKeys == nil then
    local keys = {}
    for _, cat in ipairs(MODIFIER_CATS) do
      for _, r in ipairs(p.load(cat)) do
        for k in pairs(r.stats or {}) do keys[k] = true end
      end
    end
    for k in pairs(NON_PAGE_STATS) do keys[k] = nil end
    if keys['Explosive Range'] then keys['Explosive Radius'] = true end
    _boosterKeys = keys
  end
  return _boosterKeys
end

-- Page title of a stat's reference page, or nil when no page exists.
-- Codex-hosted hubs win (link target = the codex record's slug — never
-- rebuilt from the name); otherwise booster-modified stats point at their
-- emitted reverse-lookup page, whose title IS the stat name.
function p.statPageTarget(stat)
  if not stat or stat == '' then return nil end
  local key = STAT_PAGE_ALIAS[stat] or stat
  if NON_PAGE_STATS[key] then return nil end
  local cx = p.codexFor(key, 'Tips')
  if cx and cx.slug then return cx.slug end
  if p.boosterStatKeys()[key] then return key end
  return nil
end

-- '[[<page>|<stat>]]' when the stat has a reference page, else the plain
-- label unchanged.
function p.statLabelLink(stat)
  local target = p.statPageTarget(stat)
  if not target then return stat end
  if target == stat then return '[[' .. stat .. ']]' end
  return '[[' .. target .. '|' .. stat .. ']]'
end

-- Ordered {key, value} pairs of rec.stats per STAT_ORDER (extras appended
-- alphabetically).
function p.statPairs(rec)
  local stats = (rec and rec.stats) or {}
  local known, extra = {}, {}
  for k in pairs(stats) do
    if STAT_RANK[k] then known[#known + 1] = k else extra[#extra + 1] = k end
  end
  table.sort(known, function(a, b) return STAT_RANK[a] < STAT_RANK[b] end)
  table.sort(extra)
  local out = {}
  for _, k in ipairs(known) do out[#out + 1] = {k, stats[k]} end
  for _, k in ipairs(extra) do out[#out + 1] = {k, stats[k]} end
  return out
end

-- Infobox 'Stats' cell: one glyph+label+value line per stat, <br>-joined.
-- Every stat label whose reference page exists links to it (elemental
-- damage labels to the damage-type hubs, booster-modified stats to their
-- reverse-lookup pages); page-less stats stay plain text. Values and
-- glyphs are untouched.
function p.statsBlock(rec, signed)
  local lines = {}
  for _, kv in ipairs(p.statPairs(rec)) do
    local k, v = kv[1], kv[2]
    local val = p.fmtStat(v, signed, k)
    if val ~= nil then
      local label = p.statLabelLink(k)
      local chip = p.DMG_CHIP[k]
      if chip then
        lines[#lines + 1] = p.statGlyph(k) .. '<span class="' .. chip[3]
          .. '" style="color:var(' .. chip[1] .. ', ' .. chip[2]
          .. ");\">'''" .. label .. ":''' " .. val .. '</span>'
      else
        lines[#lines + 1] = p.statGlyph(k) .. "'''" .. label .. ":''' " .. val
      end
    end
  end
  return table.concat(lines, '<br>')
end

-- One-line plain-text summary for index columns (Effects / Modifiers).
function p.effectsSummary(rec, signed)
  local parts = {}
  for _, kv in ipairs(p.statPairs(rec)) do
    local val = p.fmtStat(kv[2], signed, kv[1])
    if val ~= nil then parts[#parts + 1] = kv[1] .. ' ' .. val end
  end
  if #parts > 0 then return table.concat(parts, ', ') end
  local texts = {}
  for _, key in ipairs(rec.tags or {}) do
    local t = p.tagText(key)
    if t and t ~= '' then texts[#texts + 1] = t end
  end
  if #texts > 0 then return table.concat(texts, ' ') end
  local d = (rec.description or ''):gsub('^%s+', '')
  if d ~= '' then return mw.ustring.sub(p.firstSentence(d, 120), 1, 120) end
  return ''
end

function p.availability(locked)
  if locked then return 'Must be unlocked' end
  return 'Unlocked from start'
end

local CRUX_STATE = {
  Acquired = 'Default',
  Unlocked = 'Unlocked from start',
  Locked = 'Must be unlocked',
}

-- Melee vs Ranged: the melee weapons carry the Melee tag (or raw
-- tag_source family 'Melee'); everything else is Ranged.
function p.weaponClass(rec)
  for _, k in ipairs(rec.tags or {}) do
    if k == 'Melee' then return 'Melee' end
  end
  if rec.tag_source == 'Melee' then return 'Melee' end
  return 'Ranged'
end

-- ===================================================================
-- tags
-- ===================================================================
local _tagByKey = nil
function p.tagRec(key)
  if _tagByKey == nil then
    _tagByKey = {}
    for _, r in ipairs(p.load('Tags')) do
      _tagByKey[r.id or r.name] = r
    end
  end
  return _tagByKey[key]
end

function p.tagText(key)
  local r = p.tagRec(key)
  return (r and r.text) or ''
end

-- Tag link wrapped for the hover-tooltip JS. Slug comes from the tag's
-- own source record (never recomputed here).
function p.tagLink(key)
  local r = p.tagRec(key)
  if not r or not r.slug then return key end
  return '<span class="wm-tip" data-tip-title="' .. r.slug .. '">[['
    .. r.slug .. '|' .. key .. ']]</span>'
end

-- Body 'Tags' section: list of "<link> – <text>" strings.
function p.tagsBody(rec)
  local out = {}
  for _, key in ipairs(rec.tags or {}) do
    local text = p.tagText(key)
    if text ~= '' then
      out[#out + 1] = p.tagLink(key) .. ' – ' .. text
    else
      out[#out + 1] = p.tagLink(key)
    end
  end
  return out
end

-- Cross-category tag membership map: {tagkey -> {catName -> {rec,...}}}.
-- One scan per parse (memoized); powers tag pages + Used-by counts.
local TAG_BEARING = {'Weapons', 'Characters', 'Blessings', 'Upgrades',
                     'Passives', 'Enemies'}
p.TAG_BEARING = TAG_BEARING

local _membership = nil
function p.tagMembership()
  if _membership == nil then
    _membership = {}
    for _, cat in ipairs(TAG_BEARING) do
      for _, r in ipairs(p.load(cat)) do
        for _, key in ipairs(r.tags or {}) do
          _membership[key] = _membership[key] or {}
          _membership[key][cat] = _membership[key][cat] or {}
          local bucket = _membership[key][cat]
          bucket[#bucket + 1] = r
        end
      end
    end
  end
  return _membership
end

function p.usedByCount(key)
  local m = p.tagMembership()[key]
  if not m then return 0 end
  local n = 0
  for _, recs in pairs(m) do n = n + #recs end
  return n
end

-- ===================================================================
-- codex joins (by normalized name)
-- ===================================================================
local _codexByType = nil
local function codexIndex()
  if _codexByType == nil then
    _codexByType = {}
    for _, r in ipairs(p.load('Codex')) do
      local t = r.type or ''
      _codexByType[t] = _codexByType[t] or {}
      _codexByType[t][p.normName(r.name or r.id)] = r
    end
  end
  return _codexByType
end

-- The codex entry describing an entity (Weapons / Enemies join), or nil.
function p.codexFor(name, codexType)
  local idx = codexIndex()
  local key = p.normName(name)
  if codexType then
    return (idx[codexType] or {})[key]
  end
  for _, bucket in pairs(idx) do
    if bucket[key] then return bucket[key] end
  end
  return nil
end

-- ===================================================================
-- computed infobox (per category) — same field names/values the
-- infobox Templates and index columns have always consumed.
-- ===================================================================
local INFOBOX_BUILDERS = {
  Weapons = function(rec)
    return {
      Class = p.weaponClass(rec),
      Tier = rec.tier,
      Price = p.fmtNum(rec.price),
      Availability = p.availability(rec.locked),
      Stats = p.statsBlock(rec, false),
      Damage = rec.stats and rec.stats['Damage'] ~= nil
        and p.fmtNum(rec.stats['Damage']) or nil,
      ['Attack Speed'] = rec.stats and rec.stats['Attack Speed'] ~= nil
        and p.fmtNum(rec.stats['Attack Speed']) or nil,
    }
  end,
  Characters = function(rec)
    local hp = rec.stats and rec.stats['Health Points']
    return {
      Availability = p.availability(rec.locked),
      Stats = p.statsBlock(rec, true),
      Modifiers = p.effectsSummary(rec, true),
      ['Health Points'] = hp ~= nil and p.fmtStat(hp, true) or nil,
    }
  end,
  Crux = function(rec)
    return {
      Type = rec.type,
      Availability = CRUX_STATE[rec.state] or rec.state or '',
      Stats = p.statsBlock(rec, true),
      Effects = p.effectsSummary(rec, true),
    }
  end,
  Enemies = function(rec)
    local ib = {Stats = p.statsBlock(rec, false)}
    if rec.tags and #rec.tags > 0 then
      local links = {}
      for _, k in ipairs(rec.tags) do links[#links + 1] = p.tagLink(k) end
      ib.Classification = table.concat(links, ', ')
    end
    local st = rec.stats or {}
    for _, k in ipairs({'Health', 'Damage', 'Speed', 'Resources'}) do
      if st[k] ~= nil then ib[k] = p.fmtNum(st[k]) end
    end
    if st['HP per Wave'] ~= nil then ib['HP/Wave'] = p.fmtNum(st['HP per Wave']) end
    if st['Damage per Wave'] ~= nil then ib['Dmg/Wave'] = p.fmtNum(st['Damage per Wave']) end
    return ib
  end,
  Achievements = function(rec)
    return {Objective = rec.description or ''}
  end,
  Codex = function(rec)
    return {
      Type = rec.type,
      Summary = p.firstSentence(rec.description or '', 140),
    }
  end,
  Tags = function(rec)
    local text = rec.text or ''
    if text == '' then text = "''(no effect text provided by the game data)''" end
    return {Effect = text, ['Used by'] = p.usedByCount(rec.id or rec.name)}
  end,
}
local function shopInfobox(rec)
  return {
    Tier = rec.tier,
    Price = p.fmtNum(rec.price),
    Availability = p.availability(rec.locked),
    Stats = p.statsBlock(rec, true),
    Effects = p.effectsSummary(rec, true),
  }
end
INFOBOX_BUILDERS.Blessings = shopInfobox
INFOBOX_BUILDERS.Upgrades = shopInfobox
INFOBOX_BUILDERS.Passives = shopInfobox

function p.computedInfobox(cat, rec)
  local b = INFOBOX_BUILDERS[cat]
  if not b then return {} end
  return b(rec)
end

-- ===================================================================
-- body sections (computed)
-- ===================================================================
function p.bodySection(cat, rec, section)
  if section == 'Tags' then
    return p.tagsBody(rec)
  end
  if section == 'Description' or section == 'Objective' then
    return rec.description or ''
  end
  if section == 'Lore' then
    if cat == 'Weapons' or cat == 'Enemies' then
      local cx = p.codexFor(rec.name, cat)
      if cx and cx.description and cx.description ~= '' then
        return cx.description
      end
      if cat == 'Enemies' then return rec.description or '' end
      return ''
    end
    -- Characters / Codex: own prose.
    return rec.description or ''
  end
  return ''
end

local BODY_ORDER = {
  Weapons = {'Lore', 'Tags'},
  Characters = {'Lore', 'Tags'},
  Blessings = {'Description', 'Tags'},
  Upgrades = {'Description', 'Tags'},
  Passives = {'Description', 'Tags'},
  Crux = {'Description'},
  Enemies = {'Lore'},
  Achievements = {'Objective'},
  Codex = {'Lore'},
  Tags = {},
}

-- ===================================================================
-- sorting (per-category index order, computed at render time so the
-- source page order never matters)
-- ===================================================================
local CRUX_TYPE_ORDER = {Unique = 1, Action = 2, Buff = 3, Debuff = 4, Aura = 5}
local CODEX_TYPE_ORDER = {Enemies = 1, Weapons = 2, Events = 3, Tips = 4}
local ROMAN = {I = 1, II = 2, III = 3, IV = 4, V = 5}

local function familyKey(name)
  local head, tail = name:match('^(.*)%s(%S+)$')
  if head and ROMAN[tail] then
    return mw.ustring.lower(head), ROMAN[tail]
  end
  if name:sub(-5) == ' Plus' then
    return mw.ustring.lower(name:sub(1, -6)), 2
  end
  return mw.ustring.lower(name), 0
end

local SORT_KEYS = {
  Weapons = function(r) return {tonumber(r.tier) or 9, mw.ustring.lower(r.name or '')} end,
  Characters = function(r) return {mw.ustring.lower(r.name or '')} end,
  Blessings = function(r) return {tonumber(r.tier) or 9, mw.ustring.lower(r.name or '')} end,
  Upgrades = function(r) return {tonumber(r.tier) or 9, mw.ustring.lower(r.name or '')} end,
  Passives = function(r)
    local fam, rank = familyKey(r.name or '')
    return {fam, rank, r.name or ''}
  end,
  Crux = function(r) return {CRUX_TYPE_ORDER[r.type] or 9, mw.ustring.lower(r.name or '')} end,
  Enemies = function(r) return {mw.ustring.lower(r.name or '')} end,
  Achievements = function(r) return {mw.ustring.lower(r.name or '')} end,
  Codex = function(r) return {CODEX_TYPE_ORDER[r.type] or 9, mw.ustring.lower(r.name or '')} end,
  Tags = function(r) return {mw.ustring.lower(r.name or '')} end,
}

local function keyLess(a, b)
  for i = 1, math.max(#a, #b) do
    local x, y = a[i], b[i]
    if x == nil then return true end
    if y == nil then return false end
    if x ~= y then
      if type(x) == 'number' and type(y) == 'number' then return x < y end
      return tostring(x) < tostring(y)
    end
  end
  return false
end

-- Stable per-category sort of the records list (returns a new list).
function p.sorted(cat)
  local keyfn = SORT_KEYS[cat] or function(r) return {mw.ustring.lower(r.name or '')} end
  local list = {}
  for i, r in ipairs(p.load(cat)) do list[#list + 1] = {i = i, r = r, k = keyfn(r)} end
  table.sort(list, function(a, b)
    if keyLess(a.k, b.k) then return true end
    if keyLess(b.k, a.k) then return false end
    return a.i < b.i
  end)
  local out = {}
  for _, e in ipairs(list) do out[#out + 1] = e.r end
  return out
end

-- ===================================================================
-- index page config + entry points shared by the per-category shims
-- ===================================================================
local INDEX_OPTS = {
  Weapons = {columns = {{name = 'Thumbnail', source = 'thumbnail'}, {name = 'Name', source = 'link'}, {name = 'Tier', source = 'Tier'}, {name = 'Price', source = 'Price'}, {name = 'Damage', source = 'Damage'}, {name = 'Attack Speed', source = 'Attack Speed'}}, thumbnail_size = 48},
  Characters = {columns = {{name = 'Thumbnail', source = 'thumbnail'}, {name = 'Name', source = 'link'}, {name = 'Modifiers', source = 'Modifiers'}, {name = 'Health Points', source = 'Health Points'}}, thumbnail_size = 64},
  Blessings = {columns = {{name = 'Thumbnail', source = 'thumbnail'}, {name = 'Name', source = 'link'}, {name = 'Tier', source = 'Tier'}, {name = 'Price', source = 'Price'}, {name = 'Effects', source = 'Effects'}}, thumbnail_size = 48},
  Upgrades = {columns = {{name = 'Thumbnail', source = 'thumbnail'}, {name = 'Name', source = 'link'}, {name = 'Tier', source = 'Tier'}, {name = 'Price', source = 'Price'}, {name = 'Effects', source = 'Effects'}}, thumbnail_size = 48},
  Passives = {columns = {{name = 'Thumbnail', source = 'thumbnail'}, {name = 'Name', source = 'link'}, {name = 'Tier', source = 'Tier'}, {name = 'Price', source = 'Price'}, {name = 'Effects', source = 'Effects'}}, thumbnail_size = 48},
  Crux = {columns = {{name = 'Thumbnail', source = 'thumbnail'}, {name = 'Name', source = 'link'}, {name = 'Type', source = 'Type'}, {name = 'Effects', source = 'Effects'}}, thumbnail_size = 48},
  Enemies = {columns = {{name = 'Thumbnail', source = 'thumbnail'}, {name = 'Name', source = 'link'}, {name = 'Health', source = 'Health'}, {name = 'HP/Wave', source = 'HP/Wave'}, {name = 'Damage', source = 'Damage'}, {name = 'Dmg/Wave', source = 'Dmg/Wave'}, {name = 'Speed', source = 'Speed'}, {name = 'Resources', source = 'Resources'}}, thumbnail_size = 48},
  Achievements = {columns = {{name = 'Thumbnail', source = 'thumbnail'}, {name = 'Name', source = 'link'}, {name = 'Objective', source = 'Objective'}}, thumbnail_size = 48},
  Codex = {columns = {{name = 'Thumbnail', source = 'thumbnail'}, {name = 'Name', source = 'link'}, {name = 'Type', source = 'Type'}, {name = 'Summary', source = 'Summary'}}, thumbnail_size = 48},
  Tags = {columns = {{name = 'Tag', source = 'link'}, {name = 'Effect', source = 'Effect'}, {name = 'Used by', source = 'Used by'}}, thumbnail_size = 16},
}

-- Visible infobox fields per category — MUST match the rows declared in
-- Template:<Cat>_infobox.
local INFOBOX_FIELD_ORDER = {
  Weapons = {'Tier', 'Price', 'Availability', 'Stats', 'Class'},
  Characters = {'Availability', 'Stats'},
  Blessings = {'Tier', 'Price', 'Availability', 'Stats'},
  Upgrades = {'Tier', 'Price', 'Availability', 'Stats'},
  Passives = {'Tier', 'Price', 'Stats'},
  Crux = {'Type', 'Availability', 'Stats'},
  Enemies = {'Classification', 'Stats'},
  Achievements = {},
  Codex = {'Type'},
  Tags = {'Effect', 'Used by'},
}

-- Record (shallow copy) augmented with the computed infobox dict, so the
-- pure Module:Index renderer keeps working unchanged.
function p.augmented(cat, rec)
  local out = {}
  for k, v in pairs(rec) do out[k] = v end
  out.infobox = p.computedInfobox(cat, rec)
  return out
end

local function resolveRec(cat, frame)
  local id = frame and frame.args and (frame.args.id or frame.args[1])
  if id then id = (tostring(id):gsub('^%s+', ''):gsub('%s+$', '')) end
  if not id or id == '' then return nil, '' end
  local rec = p.byId(cat)[id]
  if not rec then
    return nil, mw.getCurrentFrame():preprocess(
      '<i>Unknown ' .. cat .. ' record: ' .. mw.text.nowiki(id) .. '</i>')
  end
  return rec, nil
end
p.resolveRec = resolveRec

local function asParam(v)
  if v == nil then return '' end
  return tostring(v)
end

local function renderInfobox(cat, rec)
  local ib = p.computedInfobox(cat, rec)
  local out = {'{{' .. cat .. '_infobox'}
  out[#out + 1] = '| name = ' .. asParam(rec.name)
  out[#out + 1] = '| id   = ' .. asParam(rec.id)
  out[#out + 1] = '| icon = ' .. asParam(rec.icon)
  for _, field in ipairs(INFOBOX_FIELD_ORDER[cat] or {}) do
    out[#out + 1] = '| ' .. field .. ' = ' .. asParam(ib[field])
  end
  out[#out + 1] = '}}'
  return table.concat(out, '\n')
end

local function renderSectionBody(content)
  if content == nil or content == '' then return '' end
  if type(content) == 'table' then
    if #content == 0 then return '' end
    local lines = {}
    for _, line in ipairs(content) do lines[#lines + 1] = '* ' .. tostring(line) end
    return table.concat(lines, '\n')
  end
  return tostring(content)
end

-- ----- entry points used by the per-category shim modules -------------

function p.infoboxEntry(cat, frame)
  local rec, missing = resolveRec(cat, frame)
  if missing then return missing end
  if not rec then return '' end
  return mw.getCurrentFrame():preprocess(renderInfobox(cat, rec))
end

function p.bodyEntry(cat, frame)
  local rec, missing = resolveRec(cat, frame)
  if missing then return missing end
  if not rec then return '' end
  local section = frame and frame.args and frame.args.section
  if section and section ~= '' then
    return mw.getCurrentFrame():preprocess(
      renderSectionBody(p.bodySection(cat, rec, section)))
  end
  local out = {}
  for _, sec in ipairs(BODY_ORDER[cat] or {}) do
    local body = renderSectionBody(p.bodySection(cat, rec, sec))
    if body ~= '' then
      out[#out + 1] = ''
      out[#out + 1] = '== ' .. sec .. ' =='
      out[#out + 1] = body
    end
  end
  return mw.getCurrentFrame():preprocess(table.concat(out, '\n'))
end

function p.indexEntry(cat, frame)
  local Index = require('Module:Index')
  local recs = {}
  for _, r in ipairs(p.sorted(cat)) do
    recs[#recs + 1] = p.augmented(cat, r)
  end
  -- Linkify stat-name column headers to their reference pages (existence-
  -- checked via statPageTarget; non-stat headers pass through untouched).
  -- Works on a copy so the shared INDEX_OPTS table is never mutated.
  local base = INDEX_OPTS[cat] or {}
  local opts = {thumbnail_size = base.thumbnail_size,
                sort_default = base.sort_default, columns = {}}
  for _, c in ipairs(base.columns or {}) do
    local nm = c.name
    local target = p.statPageTarget(nm)
    if target then
      nm = (target == nm) and ('[[' .. nm .. ']]')
        or ('[[' .. target .. '|' .. nm .. ']]')
      if base.sort_default == c.name then opts.sort_default = nm end
    end
    opts.columns[#opts.columns + 1] = {name = nm, source = c.source}
  end
  return Index.render(recs, opts)
end

function p.renderEntry(cat, frame)
  local rec, missing = resolveRec(cat, frame)
  if missing then return missing end
  if not rec then return '' end
  local CrossRef = require('Module:CrossRef')
  local parts = {
    renderInfobox(cat, rec),
    p.bodyEntry(cat, frame),
    CrossRef.compute(cat, rec),
  }
  return mw.getCurrentFrame():preprocess(table.concat(parts, '\n'))
end

return p