From Arms of God Wiki
bot: dark theme for remaining light surfaces (hand-authored tables, /doc + notice panels) |
bot: operator iteration 2026-06-10c (inline lore restore / codex redirects / passives grouping / credit footer removal) |
||
| Line 114: | Line 114: | ||
-- gen-time. They MUST exactly match the labels flatten emits. | -- gen-time. They MUST exactly match the labels flatten emits. | ||
local INFOBOX_FIELD_ORDER = {'Classification', 'Stats'} -- {'Tier', 'Value', ...} | local INFOBOX_FIELD_ORDER = {'Classification', 'Stats'} -- {'Tier', 'Value', ...} | ||
local BODY_SECTION_ORDER = {' | local BODY_SECTION_ORDER = {'Lore'} -- {'Description', ...} | ||
local CROSS_REF_LABEL_ORDER = { | local CROSS_REF_LABEL_ORDER = {} -- {'Found on items', ...} | ||
-- INDEX_OPTS lives at module scope so both p.index AND p.filter / p.subtable | -- INDEX_OPTS lives at module scope so both p.index AND p.filter / p.subtable | ||
-- read the SAME column list + thumbnail_size. Before F1, INDEX_OPTS was only | -- read the SAME column list + thumbnail_size. Before F1, INDEX_OPTS was only | ||
Revision as of 05:00, 10 June 2026
Renders Enemies records from Data:Enemies.json on index and detail pages.
infobox— infobox for one record (used via Template:Enemies infobox)index— sortable index table on Enemiesbody/crossRefs— per-section renderers invoked from detail pages
Bot-published: the module and its Data: page are regenerated on re-publish. Restyle via the per-section templates rather than editing page wikitext.
-- ====================================================================
-- LIBRARY MODULE: base_render
-- Skeleton for a per-category Module:Enemies.lua. Reads the Phase
-- 4.5 flattened data shape (see 4.5-flatten/README.md) where every
-- record arrives wiki-ready:
-- * locale keys already resolved (no tr() at render time)
-- * effect templates already substituted (no format() at render time)
-- * res:// paths already turned into wiki File: names (no Data:images
-- cross-walk)
-- * slug already on the record (no Slugs.lookup at render time)
-- * forward AND reverse cross-references already inlined as
-- {slug, name, icon} summaries (no module-load reverse-index build)
--
-- This module is therefore thin formatting glue. It does NOT call tr(),
-- format(), Slugs.lookup, mw.ext.data.get, or any other resolver.
--
-- F-classes prevented:
-- F1 — link targets come from rec.cross_refs[*].slug, never
-- recomputed; the only string ops on display names happen in
-- Phase 4.5 (Python). No gsub/lower/replace lives here.
-- F2 — every return that contains {{Template:...}} (which the
-- infobox call does) is piped through
-- mw.getCurrentFrame():preprocess(...). #invoke does NOT
-- re-parse returned text for templates by default.
-- F4-adjacent — no template-arg substitution at render time; effect
-- strings arrive pre-substituted.
-- F9 — row decisions live in Template:Enemies_infobox (Pattern
-- B from base_infobox.wiki). This Lua never emits `|-` or `|`
-- inside a `#if` body — it always passes named params to the
-- template, which handles the empty-cell case uniformly.
-- loadJson-frozen-table — every JSON read goes through `loadJson`,
-- which canonicalises mw.loadJsonData's frozen output once
-- (recursive copy with stringified-integer-key aliases) so
-- `#`, ipairs, t[id], and table.concat behave normally.
--
-- Consumes plan.json fields (via wiki-builder substitutions below):
-- categories[].name → Enemies
-- categories[].entity_source → not used in Lua anymore; flatten
-- writes Data:Enemies.json keyed
-- by the category name, not the
-- entity-source stem.
-- categories[].infobox_fields[].field → INFOBOX_FIELD_ORDER
-- categories[].body_sections[].name → BODY_SECTION_ORDER
-- categories[].cross_references[] + reverse-references derived
-- from every other category pointing at this one
-- → CROSS_REF_LABEL_ORDER
-- categories[].index_page → INDEX_OPTS
--
-- Substitution slots the wiki-builder fills:
-- Enemies reader-facing category, e.g. "Items"
-- Enemies_infobox name of the infobox template, e.g.
-- "Items_infobox"
-- INFOBOX_FIELD_ORDER Lua list: {'Tier','Value','Effects'} —
-- the order in which to pipe rec.infobox
-- values as named template params
-- BODY_SECTION_ORDER Lua list: {'Description'} — body section
-- render order
-- CROSS_REF_LABEL_ORDER Lua list: {'Found on items',
-- 'Referenced by characters'} — exactly the
-- label strings flatten.py writes into
-- rec.cross_refs
-- INDEX_OPTS Lua dict literal for Module:Index.render
-- ====================================================================
local CrossRef = require('Module:CrossRef')
local p = {}
-- F-prevention (SC-01): canonicalise the frozen JSON table once at
-- load time. After this, `#`, ipairs, t[id], and table.concat work as
-- if the data came from a regular Lua table.
local function loadJson(name)
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
return fix(mw.loadJsonData(name))
end
-- Module-load cache: pull the flattened category file once. Shape:
-- { category = "Enemies", aggregation_mode = "none|by_field|by_table",
-- record_count = N, records = { {id, slug, name, icon, infobox,
-- body, cross_refs, ...}, ... } }
local _data = nil
local function dataset()
if _data == nil then
_data = loadJson('Data:Enemies.json') or {records = {}}
end
return _data
end
-- Reverse index for O(1) record lookup. The flatten output is a list
-- (ordered for index-page determinism), but render(id) is keyed. We
-- accept id, slug, OR family_key as the lookup key — all three are
-- emitted as a 3-line stub by 5-build-pages/code/emit_<cat>_stubs.py.
local _byId = nil
local function byId()
if _byId == nil then
_byId = {}
for _, r in ipairs(dataset().records or {}) do
if r.id then _byId[r.id] = r end
if r.slug then _byId[r.slug] = r end
if r.family_key then _byId[r.family_key] = r end
end
end
return _byId
end
-- The wiki-builder pastes the per-category order lists here at
-- gen-time. They MUST exactly match the labels flatten emits.
local INFOBOX_FIELD_ORDER = {'Classification', 'Stats'} -- {'Tier', 'Value', ...}
local BODY_SECTION_ORDER = {'Lore'} -- {'Description', ...}
local CROSS_REF_LABEL_ORDER = {} -- {'Found on items', ...}
-- INDEX_OPTS lives at module scope so both p.index AND p.filter / p.subtable
-- read the SAME column list + thumbnail_size. Before F1, INDEX_OPTS was only
-- inlined inside p.index()'s body literal, which meant p.filter's flat-path
-- fallback (`local cols = (INDEX_OPTS and INDEX_OPTS.columns) or {}`) always
-- saw nil and returned ''. Hoisting it here makes the flat-path code actually
-- work for non-subtable cross_view targets (Quests → Merchants, etc.).
local INDEX_OPTS = {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} -- {columns = {...}, thumbnail_size = N, sort_default = '...'}
-- Coerce a flattened value to a template-param string. Lists become
-- comma-separated; numbers become their textual form; nil/empty
-- become empty string (the template's Pattern B turns empty into '—').
local function asParam(v)
if v == nil then return '' end
if type(v) == 'table' then
-- Lists of strings (effects, tags) → ', '.join. Lists of dicts
-- (rare; e.g. nested xref summaries) → render names joined.
local parts = {}
for i = 1, #v do
local item = v[i]
if type(item) == 'table' and item.name then
table.insert(parts, tostring(item.name))
else
table.insert(parts, tostring(item))
end
end
return table.concat(parts, ', ')
end
return tostring(v)
end
-- Build a {{Enemies_infobox | ...}} call from rec.infobox in
-- plan-declared order. Always emits `name`, `id`, `icon` slots so the
-- template can render the header/icon row uniformly; then one
-- `| <field> = <value>` per INFOBOX_FIELD_ORDER entry. Empty values
-- pass through; Pattern B in the template renders the em-dash.
local function renderInfobox(rec)
local out = {'{{Enemies_infobox'}
table.insert(out, '| name = ' .. asParam(rec.name))
table.insert(out, '| id = ' .. asParam(rec.id))
table.insert(out, '| icon = ' .. asParam(rec.icon))
local infobox = rec.infobox or {}
for _, field in ipairs(INFOBOX_FIELD_ORDER) do
table.insert(out, '| ' .. field .. ' = ' .. asParam(infobox[field]))
end
table.insert(out, '}}')
return table.concat(out, '\n')
end
-- Aggregated categories carry rec.tier_table = {columns = {...},
-- rows = {{...}, {...}, ...}}; render as a sortable wikitable.
-- Non-aggregated records have rec.tier_table == nil → returns ''.
local function renderTierTable(rec)
local tt = rec.tier_table
if not tt or not tt.columns or #tt.columns == 0 then return '' end
local out = {'', '{| class="wikitable sortable" style="background:var(--table-row-odd, #1b1c20);color:var(--table-text, #e6e6e6);border-color:var(--table-border, #3a3c44);"', '|-'}
for _, col in ipairs(tt.columns) do
table.insert(out, '! style="background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);" | ' .. tostring(col))
end
for _, row in ipairs(tt.rows or {}) do
table.insert(out, '|-')
local cells = {}
for _, cell in ipairs(row) do
table.insert(cells, tostring(cell))
end
table.insert(out, '| ' .. table.concat(cells, ' || '))
end
table.insert(out, '|}')
return table.concat(out, '\n')
end
-- Render one section's body content (no heading). Returns '' when the
-- content is nil / empty string / empty list (callers skip the section
-- entirely when this returns '').
--
-- List items that are dicts carrying {slug, name} (Phase 4.5 cross-ref
-- summary shape) render as [[slug|name]] wikilinks rather than the Lua
-- tostring() of a table, which would print literal "table".
--
-- Shared by p.body (per-section invoke) and renderBody (full-body in
-- p.render). Splitting it out replaces the prior approach where p.body
-- mutated BODY_SECTION_ORDER + regex-stripped the heading from
-- renderBody's output — that coupled p.body to renderBody's whitespace
-- shape. The shared helper has no string parsing.
local function _renderSectionBody(content)
if content == nil or content == '' then return '' end
if type(content) == 'table' and #content == 0 then return '' end
local lines = {}
if type(content) == 'table' then
for _, line in ipairs(content) do
if type(line) == 'table' and line.slug and line.name then
table.insert(lines, '* [[' .. tostring(line.slug) .. '|' .. tostring(line.name) .. ']]')
elseif type(line) == 'table' and line.name then
table.insert(lines, '* ' .. tostring(line.name))
else
table.insert(lines, '* ' .. tostring(line))
end
end
else
table.insert(lines, tostring(content))
end
return table.concat(lines, '\n')
end
-- Body sections, in plan-declared order. Skips sections whose source
-- value is empty. Emits the heading + the section body (via
-- _renderSectionBody) per section.
local function renderBody(rec)
local body = rec.body or {}
local out = {}
for _, section in ipairs(BODY_SECTION_ORDER) do
local body_text = _renderSectionBody(body[section])
if body_text ~= '' then
table.insert(out, '')
table.insert(out, '== ' .. section .. ' ==')
table.insert(out, body_text)
end
end
return table.concat(out, '\n')
end
-- Cross-references: pre-populated by Phase 4.5 in both directions.
-- Just iterate the label order and hand each list to CrossRef.
local function renderCrossRefs(rec)
return CrossRef.renderAll(rec, CROSS_REF_LABEL_ORDER)
end
-- ----- entry points --------------------------------------------------
--
-- DETAIL PAGE COMPOSITION (since 2026-05).
--
-- Phase 5 emits per-instance detail pages as composed wikitext that
-- interleaves inline_prose / inline_template / delegated_template
-- sections per body_sections[].render_mode in plan.json. Each
-- delegated_template section is wrapped in a small per-section
-- template (Template:Enemies_<section>) that calls one of the
-- entry points below — `p.infobox`, `p.body`, `p.crossRefs`,
-- `p.tierTable`. So one detail page typically issues 3-5 invokes
-- of THIS module on Scribunto's per-page Lua context.
--
-- This works at default 50 MiB Scribunto cap only for small
-- Data:<Cat>.json (<500 KiB). For larger categories, bump the host's
-- Scribunto memory limit (see 6-publish/SETUP.md "Scribunto memory")
-- or fall back to one combined invoke via `p.render` below.
--
-- `p.render` remains for back-compat and as the single-invoke
-- combined fallback. New plans use the composition pattern.
-- Per-section entry points. Each takes `frame.args.id` and returns
-- only that section's rendered wikitext, preprocessed for templates.
-- Called by per-section templates emitted by Phase 5.
local function _resolveRec(frame)
local id = frame and frame.args and frame.args.id
if not id or id == '' then return nil, '' end
local rec = byId()[id]
if not rec then
return nil, mw.getCurrentFrame():preprocess(
"<i>Unknown Enemies record: " .. mw.text.nowiki(id) .. "</i>")
end
return rec, nil
end
-- {{#invoke:Enemies|infobox|id=<slug-or-id>}}
function p.infobox(frame)
local rec, missing = _resolveRec(frame)
if missing then return missing end
if not rec then return '' end
return mw.getCurrentFrame():preprocess(renderInfobox(rec))
end
-- {{#invoke:Enemies|body|id=<slug-or-id>[|section=<section_name>]}}
-- Without `section`: renders every BODY_SECTION_ORDER entry with headings.
-- With `section`: renders only that one section's body, NO `== heading ==`
-- (because Phase 5 emitted the heading in the per-instance page wikitext
-- itself so editors can see + reorder it).
--
-- Both branches share `_renderSectionBody` — no regex parsing, no mutation
-- of BODY_SECTION_ORDER. The per-section path looks up rec.body[section]
-- directly; the full path delegates to renderBody.
function p.body(frame)
local rec, missing = _resolveRec(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
local body_text = _renderSectionBody((rec.body or {})[section])
if body_text == '' then return '' end
return mw.getCurrentFrame():preprocess(body_text)
end
return mw.getCurrentFrame():preprocess(renderBody(rec))
end
-- {{#invoke:Enemies|crossRefs|id=<slug-or-id>}}
function p.crossRefs(frame)
local rec, missing = _resolveRec(frame)
if missing then return missing end
if not rec then return '' end
return mw.getCurrentFrame():preprocess(renderCrossRefs(rec))
end
-- {{#invoke:Enemies|render|id=<slug-or-id>}} (combined / back-compat).
-- The per-instance detail page composition pattern (Phase 5 emits one
-- {{#invoke}} per delegated_template body section) is the new default.
-- This combined entry point stays for:
-- * back-compat with detail pages still emitted as 1-line stubs
-- before the composition migration
-- * single-invoke fallback when categories[].detail_page_invoke_budget
-- in a future plan extension forces one combined call to stay under
-- a tight Scribunto memory cap.
function p.render(frame)
local rec, missing = _resolveRec(frame)
if missing then return missing end
if not rec then return '' end
local parts = {
renderInfobox(rec),
renderTierTable(rec),
renderBody(rec),
renderCrossRefs(rec),
}
-- F2 (SC-03): the {{Enemies_infobox ...}} call above renders as literal
-- text without this wrap. Helper functions use mw.getCurrentFrame()
-- rather than the `frame` arg (SC-02).
return mw.getCurrentFrame():preprocess(table.concat(parts, '\n'))
end
-- Index page entry point. Called from Enemies.wiki:
-- {{#invoke:Enemies|index}}
function p.index(frame)
local Index = require('Module:Index')
return Index.render(dataset().records or {}, INDEX_OPTS or {})
end
-- ----- subtable entry point (multi-subtable index categories) ----------
--
-- Some categories (Weapons / Armor / Accessories / Monsters under D&D's
-- plan) render their index page as one wikitable per logical grouping —
-- weapon class, armor slot, monster class_type — rather than one flat
-- table. The flatten layer pre-buckets records into ``dataset().subtables``
-- with per-subtable column selection (≥50% stat-presence rule), so the
-- index page is just a sequence of:
-- {{#invoke:Enemies|subtable|key=Sword}}
-- {{#invoke:Enemies|subtable|key=Axe}}
-- ...
-- The flatten output's ``display_subtype`` field provides the reader-
-- friendly heading text (e.g. 'MagicStuff' → 'Magic Foci & Spellbooks');
-- when absent we fall back to the raw subtype.
--
-- The Lua-driven path replaces a Python-pre-rendered wikitext path that
-- baked rows into the index page at emit time. Reader-visible output is
-- identical; the win is that any change to ``Data:Enemies.json``
-- propagates on a Module/Template purge — no re-emit-then-republish.
local function _renderCell(cell, col_name, thumbnail_size)
if col_name == 'Thumbnail' then
if cell == nil or cell == '' then return '—' end
return string.format('[[File:%s|%dpx|link=]]',
tostring(cell), thumbnail_size or 48)
end
if type(cell) == 'table' and cell.slug and cell.name then
if cell.slug ~= '' then
return '[[' .. tostring(cell.slug) .. '|' .. tostring(cell.name) .. ']]'
end
return tostring(cell.name)
end
if cell == nil or cell == '' then return '—' end
return tostring(cell)
end
local function _renderSubtable(st, thumbnail_size)
local cols = st.columns or {}
local rows = st.rows or {}
local display = st.display_subtype or st.subtype or ''
local out = {}
table.insert(out, '=== ' .. tostring(display) .. ' (' .. tostring(#rows) .. ') ===')
table.insert(out, '')
table.insert(out, '{| class="wikitable sortable" style="font-size:90%;background:var(--table-row-odd, #1b1c20);color:var(--table-text, #e6e6e6);border-color:var(--table-border, #3a3c44);"')
table.insert(out, '|-')
local hdr = {}
for i = 1, #cols do hdr[i] = tostring(cols[i]) end
table.insert(out, '! style="background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);" | ' .. table.concat(hdr, ' !! style="background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);" | '))
for _, row in ipairs(rows) do
table.insert(out, '|-')
local cells = {}
for i = 1, #cols do
cells[i] = _renderCell(row[i], cols[i], thumbnail_size)
end
table.insert(out, '| ' .. table.concat(cells, ' || '))
end
table.insert(out, '|}')
return table.concat(out, '\n')
end
-- Multi-subtable index entry point. Called from Enemies.wiki:
-- {{#invoke:Enemies|subtable|key=Sword}}
-- Iterates dataset().subtables in declared order, returns the first whose
-- ``subtype`` matches ``key``. Returns an italic "not found" stub rather
-- than raising so a one-off mismatch on the index page doesn't break the
-- whole render.
function p.subtable(frame)
local key = frame.args and frame.args.key
if not key or key == '' then return '' end
local thumb = (INDEX_OPTS and INDEX_OPTS.thumbnail_size) or 48
for _, st in ipairs(dataset().subtables or {}) do
if st.subtype == key then
return mw.getCurrentFrame():preprocess(_renderSubtable(st, thumb))
end
end
return mw.getCurrentFrame():preprocess(
"<i>Unknown Enemies subtable key: " .. mw.text.nowiki(key) .. "</i>")
end
-- ----- filter entry point (F1: cross-context filtered embedding) --------
--
-- Render this category's records filtered by `by=<field>` / `value=<v>`.
-- Used to syndicate one category's table onto another category's detail
-- pages — e.g. on the Barbarian Class page:
-- {{#invoke:Weapons|filter|by=class_requirements|value=Barbarian}}
-- emits the standard Weapons sortable wikitable, restricted to weapons
-- whose `class_requirements` field includes "Barbarian".
--
-- Predicate semantics (in `_matchesPredicate`):
-- record[by] is a string → equality.
-- record[by] is a list of strings → membership.
-- record[by] is a list of dicts with `name` → membership on names.
-- `by` contains a `.` → walked as a dotted path (record.a.b),
-- then the leaf is tested as above.
--
-- Rendering:
-- When dataset().subtables exists, the matched slugs are looked up in
-- each subtable's pre-computed rows so the column shape and cell
-- formatting are IDENTICAL to the index page's per-class subtable.
-- Subtable groupings are preserved (one ===<subtype>=== heading per
-- non-empty group).
-- Otherwise we fall back to INDEX_OPTS.columns and render a single
-- flat wikitable using the same _renderCell helper.
--
-- Optional `limit=N` caps the total rendered row count (0 / omitted /
-- non-positive = no cap). When `limit` truncates, a trailing italic note
-- announces it.
-- Empty match set returns '' (suppressed; the caller's section header
-- still emits, so this is intentionally quiet).
local function _walkPath(record, path)
if record == nil or path == nil or path == '' then return nil end
if string.find(path, '.', 1, true) == nil then
return record[path]
end
local cur = record
for part in string.gmatch(path, '[^.]+') do
if type(cur) ~= 'table' then return nil end
cur = cur[part]
if cur == nil then return nil end
end
return cur
end
local function _matchesPredicate(record, by, value)
local v = _walkPath(record, by)
if v == nil then return false end
if type(v) == 'table' then
-- list-of-strings or list-of-dicts-with-name
for i = 1, #v do
local item = v[i]
if type(item) == 'table' then
if item.name ~= nil and tostring(item.name) == value then
return true
end
if item.slug ~= nil and tostring(item.slug) == value then
return true
end
elseif tostring(item) == value then
return true
end
end
return false
end
return tostring(v) == value
end
local function _renderFlatTable(records, cols, thumb)
-- One header row + N data rows. `cols` is a list of {name, source} dicts
-- (the INDEX_OPTS shape). Resolves each cell with _renderCell so the
-- Thumbnail / cross-ref-dict / nil-as-em-dash conventions all match the
-- subtable renderer.
if #cols == 0 then return '' end
local out = {
'{| class="wikitable sortable" style="font-size:90%;background:var(--table-row-odd, #1b1c20);color:var(--table-text, #e6e6e6);border-color:var(--table-border, #3a3c44);"',
'|-',
}
local hdr = {}
for i = 1, #cols do hdr[i] = tostring(cols[i].name or cols[i]) end
table.insert(out, '! style="background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);" | ' .. table.concat(hdr, ' !! style="background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);" | '))
for _, rec in ipairs(records) do
table.insert(out, '|-')
local cells = {}
for i = 1, #cols do
local col = cols[i]
local col_name = tostring(col.name or col)
local source = col.source or col_name
local raw
if source == 'thumbnail' or col_name == 'Thumbnail' then
raw = rec.icon
elseif source == 'name' or source == 'link' then
raw = {slug = rec.slug, name = rec.name}
else
raw = rec[source]
if raw == nil and rec.infobox ~= nil then
raw = rec.infobox[source]
end
end
cells[i] = _renderCell(raw, col_name, thumb)
end
table.insert(out, '| ' .. table.concat(cells, ' || '))
end
table.insert(out, '|}')
return table.concat(out, '\n')
end
function p.filter(frame)
local args = (frame and frame.args) or {}
local by = args.by
local value = args.value
if not by or by == '' or not value or value == '' then return '' end
local limit = tonumber(args.limit or 0) or 0
local d = dataset()
local thumb = (INDEX_OPTS and INDEX_OPTS.thumbnail_size) or 48
-- 1) Filter the records list by the predicate. Collect matched slugs +
-- matched record list for the no-subtable fallback path.
local matched_slugs = {}
local matched_recs = {}
for _, rec in ipairs(d.records or {}) do
if _matchesPredicate(rec, by, value) then
if rec.slug ~= nil then matched_slugs[tostring(rec.slug)] = true end
table.insert(matched_recs, rec)
end
end
local total_matches = #matched_recs
if total_matches == 0 then return '' end
local rendered_count = 0
local function _capped(n) -- limit==0 means no cap
if limit <= 0 then return false end
return n >= limit
end
-- 2) Subtable path — preserves the index page's column shape per group.
local subtables = d.subtables or {}
if #subtables > 0 then
local sections = {}
for _, st in ipairs(subtables) do
local cols = st.columns or {}
local rows = st.rows or {}
-- Find Name column index (column whose cell is the slug+name dict).
local name_col_idx = nil
for i = 1, #cols do
if tostring(cols[i]) == 'Name' then name_col_idx = i; break end
end
local kept = {}
for _, row in ipairs(rows) do
if _capped(rendered_count) then break end
local name_cell = name_col_idx and row[name_col_idx] or nil
local slug
if type(name_cell) == 'table' then
slug = name_cell.slug
end
if slug ~= nil and matched_slugs[tostring(slug)] then
table.insert(kept, row)
rendered_count = rendered_count + 1
end
end
if #kept > 0 then
local synth = {
subtype = st.subtype,
display_subtype = st.display_subtype or st.subtype,
columns = cols,
rows = kept,
}
table.insert(sections, _renderSubtable(synth, thumb))
end
if _capped(rendered_count) then break end
end
if #sections == 0 then return '' end
local body = table.concat(sections, '\n\n')
if limit > 0 and total_matches > rendered_count then
body = body .. '\n\n<i>(showing first ' .. tostring(rendered_count)
.. ' of ' .. tostring(total_matches) .. ' matches)</i>'
end
return mw.getCurrentFrame():preprocess(body)
end
-- 3) Fallback — flat wikitable from INDEX_OPTS.columns.
local cols = (INDEX_OPTS and INDEX_OPTS.columns) or {}
local to_render = matched_recs
if limit > 0 and #to_render > limit then
local capped = {}
for i = 1, limit do capped[i] = to_render[i] end
to_render = capped
rendered_count = limit
else
rendered_count = #to_render
end
local body = _renderFlatTable(to_render, cols, thumb)
if body == '' then return '' end
if limit > 0 and total_matches > rendered_count then
body = body .. '\n\n<i>(showing first ' .. tostring(rendered_count)
.. ' of ' .. tostring(total_matches) .. ' matches)</i>'
end
return mw.getCurrentFrame():preprocess(body)
end
-- ----- reverseRef entry point (F2: reverse cross-reference embedding) ---
--
-- Render this category's records that point back at a target value via a
-- declared field. The wiki-side intent: "what records in THIS category
-- reference THAT entity?" — surfaced on the target entity's page.
--
-- Example: on the Goblin detail page (Goblin is a Monster), emit
-- {{#invoke:Items|reverseRef|field=loot_drops|target=Goblin}}
-- to render the standard Items table restricted to items whose
-- `loot_drops` field contains "Goblin".
--
-- Semantics are IDENTICAL to p.filter; only the argument names differ:
-- p.filter : by=<field> value=<v> (filter from the source side)
-- p.reverseRef: field=<field> target=<v> (lookup from the target side)
--
-- Two arg-names exist because the wikitext call reads differently from
-- each direction. A cross_view (declared on the source category) reads
-- "filter Weapons by class_requirements = Barbarian"; a reverse_view
-- (declared on the target category) reads "find Items whose loot_drops
-- field references Goblin". Same Lua machinery; renamed args make the
-- designer's intent self-documenting in plan.json + the rendered
-- {{#invoke}} call.
--
-- Optional `limit=N` caps the rendered row count (0 / omitted = no cap),
-- same as p.filter.
function p.reverseRef(frame)
local args = (frame and frame.args) or {}
-- Translate the F2 parameter names to F1's predicate machinery.
local proxy = {
args = {
by = args.field,
value = args.target,
limit = args.limit,
},
}
return p.filter(proxy)
end
-- ----- tierTable entry point (F4: standalone per-tier comparison embed) ----
--
-- Render the per-tier comparison table for a single record. Used to surface
-- the tier_table block on detail pages (which p.render already inlines) OR
-- on cross-context pages (e.g. a `Rarity` explainer page can syndicate one
-- item-family's tier ladder via a single {{#invoke}}).
--
-- {{#invoke:Items|tierTable|id=Crystal_Sword}}
-- {{#invoke:Monsters|tierTable|id=Goblin}}
-- {{#invoke:Religions|tierTable|id=Blythar}}
--
-- Reads `dataset().byId(id).tier_table`. The flatten layer guarantees this
-- block (when present) carries `{columns = {...}, rows = {{...}, ...}}` with
-- per-tier presence already applied (no all-empty rows / no all-em-dash
-- columns) — see flatten.py for the per-category ≥50%-presence rule.
--
-- Empty / missing tier_table → returns ''. Unknown id → italic "unknown"
-- stub (same convention as p.render). Wrap in mw.getCurrentFrame():
-- preprocess(...) per SC-03 in case the cell contents carry templates
-- (consistent with renderInfobox / renderTierTable being preprocessed by
-- p.render's outer wrap).
--
-- SCRIBUNTO MEMORY CAVEAT: calling multiple {{#invoke}}s of the SAME
-- module on one page may hit Scribunto's 50 MiB memory limit when the
-- underlying Data:<Cat>.json is large. Verified failure mode: any 2nd
-- {{#invoke:Items|...}} on a single page (Items.json is ~2.5 MiB, 769
-- records) returns
-- <strong class="error">Lua error: Internal error: The interpreter
-- exited with status 1.</strong>
-- regardless of which entry point is called (tierTable, summary, filter,
-- render — they all bump into the same per-page Lua context). Smaller
-- modules (Classes, Weapons) survive 30+ invokes on one page. Mitigation:
-- (a) spread invokes across DIFFERENT modules on one page (the
-- Sandbox/F4_Summary comparison-grid pattern), (b) split the dataset into
-- smaller Data:<Cat>/<shard>.json pages so any one module loads less,
-- or (c) use a single {{#invoke|index}} / {{#invoke|filter}} call that
-- renders many records in one Lua pass.
function p.tierTable(frame)
local id = frame and frame.args and frame.args.id
if not id or id == '' then return '' end
local rec = byId()[id]
if not rec then
return mw.getCurrentFrame():preprocess(
"<i>Unknown Enemies record: " .. mw.text.nowiki(id) .. "</i>")
end
local body = renderTierTable(rec)
if body == '' then return '' end
return mw.getCurrentFrame():preprocess(body)
end
-- ----- summary entry point (F4: compact icon + name + stats cell) ----------
--
-- Render a single record as a compact, self-contained block — icon thumbnail
-- over a bolded wikilink-name over an optional inline list of key stats.
-- Designed for embedding in dense layouts (subtables, comparison grids,
-- cross-view sections that want denser-than-row presentation).
--
-- {{#invoke:Items|summary|id=Crystal_Sword}}
-- {{#invoke:Items|summary|id=Crystal_Sword|stats=Item Type,Slot}}
-- {{#invoke:Items|summary|id=Crystal_Sword|stats=Item_Type|size=32}}
-- {{#invoke:Items|summary|id=Crystal_Sword|link=false}}
--
-- Parameters (all read from frame.args):
-- id (required) — record id / slug / family_key (any byId key).
-- stats (optional) — comma-separated field names to render inline below
-- the name. Both top-level rec fields and rec.infobox
-- fields are searched; underscores in arg-names are
-- interchangeable with spaces (so the wikitext call
-- can use 'Item_Type' even when the field is
-- 'Item Type' — MediaWiki strips raw spaces from
-- URL-style args). Empty/nil values are skipped.
-- size (optional, default 48) — icon size in px.
-- link (optional, default true) — when 'false' / '0', name renders
-- as plain text, not wikilinked.
--
-- Output (one block, no trailing newline):
-- [[File:<icon>|48px|link=]]<br/>'''[[<slug>|<name>]]'''<br/><small>
-- <field>: <val> · <field>: <val></small>
--
-- Empty input (no id, or id not found) → returns '' (no italic stub —
-- summary is intended for table cells where a missing record should
-- silently render nothing, not punch a hole in the layout).
--
-- SCRIBUNTO MEMORY CAVEAT: same per-page Lua-context limit as p.tierTable
-- — calling 2+ {{#invoke:<largeModule>|summary|...}} on one page can
-- exhaust the 50 MiB budget when the underlying Data:<Cat>.json is large.
-- For comparison grids prefer SPREADING invokes across different modules
-- (e.g. {{#invoke:Weapons|summary}} + {{#invoke:Classes|summary}} +
-- {{#invoke:Monsters|summary}} renders 3 cells fine; 3× Items|summary
-- fails on the 2nd). See p.tierTable docstring above for full details.
local function _lookupField(rec, key)
-- Normalize argument key: replace underscores with spaces (and vice versa)
-- when the literal hit misses. Tries top-level, then rec.infobox.
if rec[key] ~= nil then return rec[key] end
local infobox = rec.infobox or {}
if infobox[key] ~= nil then return infobox[key] end
-- swap _ ↔ space
local alt = key:gsub('_', ' ')
if alt ~= key then
if rec[alt] ~= nil then return rec[alt] end
if infobox[alt] ~= nil then return infobox[alt] end
end
local alt2 = key:gsub(' ', '_')
if alt2 ~= key then
if rec[alt2] ~= nil then return rec[alt2] end
if infobox[alt2] ~= nil then return infobox[alt2] end
end
return nil
end
local function _isFalsy(v)
if v == nil then return true end
local s = tostring(v):lower()
return s == 'false' or s == '0' or s == 'no'
end
function p.summary(frame)
local args = (frame and frame.args) or {}
local id = args.id
if not id or id == '' then return '' end
local rec = byId()[id]
if not rec then return '' end
local size = tonumber(args.size or 48) or 48
local link_enabled = args.link == nil or not _isFalsy(args.link)
local lines = {}
-- Row 1: icon thumbnail (suppressed if no icon).
local icon = rec.icon
if icon ~= nil and icon ~= '' then
table.insert(lines, string.format('[[File:%s|%dpx|link=]]', tostring(icon), size))
end
-- Row 2: bolded name, optionally wikilinked.
local name = rec.name or rec.id or tostring(id)
local slug = rec.slug
if link_enabled and slug ~= nil and slug ~= '' then
table.insert(lines, "'''[[" .. tostring(slug) .. "|" .. tostring(name) .. "]]'''")
else
table.insert(lines, "'''" .. tostring(name) .. "'''")
end
-- Row 3: inline stats (skipped when `stats` arg absent / empty / all values nil).
local stats_arg = args.stats
if stats_arg ~= nil and stats_arg ~= '' then
local parts = {}
for key in string.gmatch(tostring(stats_arg), '[^,]+') do
-- Trim surrounding whitespace
local k = key:gsub('^%s+', ''):gsub('%s+$', '')
if k ~= '' then
local v = _lookupField(rec, k)
if v ~= nil and v ~= '' then
-- Display the key as the caller wrote it (with underscores → spaces
-- so 'Move_Speed' renders as 'Move Speed'). Value goes through
-- asParam so list-of-dicts collapses to a sane string.
local label = k:gsub('_', ' ')
table.insert(parts, label .. ': ' .. asParam(v))
end
end
end
if #parts > 0 then
table.insert(lines, '<small>' .. table.concat(parts, ' · ') .. '</small>')
end
end
if #lines == 0 then return '' end
local body = table.concat(lines, '<br/>')
return mw.getCurrentFrame():preprocess(body)
end
return p