From Arms of God Wiki

Revision as of 01:36, 10 June 2026 by Ta1ha (talk | contribs) (bot: publish Passives)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Renders Passives records from Data:Passives.json on index and detail pages.

  • infobox — infobox for one record (used via Template:Passives infobox)
  • index — sortable index table on Passives
  • body / 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:Passives.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:Passives_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             → Passives
--   categories[].entity_source    → not used in Lua anymore; flatten
--                                   writes Data:Passives.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:
--   Passives              reader-facing category, e.g. "Items"
--   Passives_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 = "Passives", 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:Passives.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    = {'Tier', 'Price', 'Stats'}     -- {'Tier', 'Value', ...}
local BODY_SECTION_ORDER     = {'Description', 'Tags'}      -- {'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 = 'Tier', source = 'Tier'}, {name = 'Price', source = 'Price'}, {name = 'Effects', source = 'Effects'}}, 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 {{Passives_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 = {'{{Passives_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"', '|-'}
  for _, col in ipairs(tt.columns) do
    table.insert(out, '! ' .. 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:Passives_<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 Passives record: " .. mw.text.nowiki(id) .. "</i>")
  end
  return rec, nil
end

-- {{#invoke:Passives|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:Passives|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:Passives|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:Passives|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 {{Passives_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 Passives.wiki:
--   {{#invoke:Passives|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:Passives|subtable|key=Sword}}
--     {{#invoke:Passives|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:Passives.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%"')
  table.insert(out, '|-')
  local hdr = {}
  for i = 1, #cols do hdr[i] = tostring(cols[i]) end
  table.insert(out, '! ' .. table.concat(hdr, ' !! '))
  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 Passives.wiki:
--   {{#invoke:Passives|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 Passives 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%"',
    '|-',
  }
  local hdr = {}
  for i = 1, #cols do hdr[i] = tostring(cols[i].name or cols[i]) end
  table.insert(out, '! ' .. table.concat(hdr, ' !! '))
  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 Passives 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