From Arms of God Wiki

Revision as of 08:29, 10 June 2026 by Ta1ha (talk | contribs) (bot: render-time derivation refactor (phase 1))
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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

-- Module:Core — shared registry, loaders and render-time computation
-- (Arms of God). THE single source of derivation logic: every table, list,
-- infobox value, badge and summary on this wiki is computed HERE (or in a
-- module that requires this one) at render time, from the source
-- Data:<Category>.json pages. There are no precomputed / derived Data pages.
--
-- Source record contract (one Data:<Cat>.json per category, array `records`):
--   id, slug, name, icon            — identity (slug computed by flatten.py,
--                                     NEVER recomputed in Lua)
--   tier, price, locked             — shop categories
--   type, state                     — Crux (type also on Codex entries)
--   tags     = {"Melee","Sword"}    — tag KEYS; text joins Data:Tags.json
--   tag_source                      — raw game tag family (weapons)
--   stats    = {["Damage"]=11, ...} — raw numeric stats ("15%" strings kept)
--   description                     — prose (lore / description / objective)
--
-- Cross-category functions (find, tagMembership, codexFor) load several
-- Data pages in one parse — requires the host's 256 MiB Scribunto cap.
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)
-- ===================================================================

-- SC-01: canonicalise mw.loadJsonData's frozen table once.
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

-- Render one stat value. nil => drop the row (disabled sentinel).
-- Mirrors flatten's historic fmt_stat_value exactly.
function p.fmtStat(v, signed)
  if type(v) == 'number' then
    if v >= 1000 then return 'Unlimited' end
    if v <= -1000 then return nil 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'},
}

-- 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.
-- Elemental damage labels link to the damage-type hub pages.
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)
    if val ~= nil then
      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]
          .. ");\">'''[[" .. k .. '|' .. k .. "]]:''' " .. val .. '</span>'
      else
        lines[#lines + 1] = p.statGlyph(k) .. "'''" .. k .. ":''' " .. 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)
    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
  return Index.render(recs, INDEX_OPTS[cat] or {})
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