From Arms of God Wiki
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