Module:GamePassives: Difference between revisions
More actions
No edit summary |
No edit summary |
||
| (One intermediate revision by the same user not shown) | |||
| Line 1: | Line 1: | ||
-- Module:GamePassives | -- Module:GamePassives | ||
-- | -- | ||
-- | -- Phase 6.5+ Slot/Grid architecture (aligned with Module:GameSkills). | ||
-- | -- | ||
-- Layout: | |||
-- Row 1: Slot 1 + Slot 2 (IconName + Description) | |||
-- Row 2: Slot 3 + Slot 4 (LevelSelector + PassiveEffects) | |||
-- | |||
-- Requires Common.js: | |||
-- - updates .sv-dyn spans via data-series | |||
-- - updates .sv-level-num + data-level on .sv-passive-card | |||
-- - binds to input.sv-level-range inside each card | |||
-- | -- | ||
-- Usage (single passive): | -- Usage (single passive): | ||
| Line 18: | Line 26: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- | -- Data cache | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
| Line 24: | Line 32: | ||
local function getPassives() | local function getPassives() | ||
if not passivesCache then | |||
passivesCache = GameData.loadPassives() | |||
end | |||
return passivesCache | |||
end | end | ||
---------------------------------------------------------------------- | |||
-- Small utilities | |||
---------------------------------------------------------------------- | |||
local function getArgs(frame) | local function getArgs(frame) | ||
local parent = frame:getParent() | |||
return (parent and parent.args) or frame.args | |||
end | end | ||
local function | local function trim(s) | ||
if type(s) ~= "string" then | |||
return nil | |||
end | |||
s = mw.text.trim(s) | |||
return (s ~= "" and s) or nil | |||
end | end | ||
local function toNum(v) | |||
local function | if type(v) == "number" then | ||
return v | |||
end | |||
if type(v) == "string" then | |||
return tonumber(v) | |||
end | |||
if type(v) == "table" and v.Value ~= nil then | |||
return toNum(v.Value) | |||
end | |||
return nil | |||
end | |||
local function clamp(n, lo, hi) | |||
if type(n) ~= "number" then | |||
return lo | |||
end | |||
if n < lo then return lo end | |||
if n > hi then return hi end | |||
return n | |||
end | end | ||
local function fmtNum(n) | |||
if type(n) ~= "number" then | |||
return (n ~= nil) and tostring(n) or nil | |||
end | |||
if math.abs(n - math.floor(n)) < 1e-9 then | |||
return tostring(math.floor(n)) | |||
end | |||
local s = string.format("%.4f", n) | |||
s = mw.ustring.gsub(s, "0+$", "") | |||
s = mw.ustring.gsub(s, "%.$", "") | |||
return s | |||
end | end | ||
local function isNoneLike(v) | |||
local function | if v == nil then return true end | ||
local s = mw.text.trim(tostring(v)) | |||
if s == "" then return true end | |||
s = mw.ustring.lower(s) | |||
return (s == "none" or s == "no" or s == "n/a" or s == "na" or s == "null") | |||
end | end | ||
local function isFlatList(list) | |||
local function | if type(list) ~= "table" or #list == 0 then | ||
return false | |||
end | |||
local first = tostring(list[1]) | |||
for i = 2, #list do | |||
if tostring(list[i]) ~= first then | |||
return false | |||
end | |||
end | |||
return true | |||
end | end | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- | -- Dynamic spans (JS-driven) | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
local function | local function dynSpan(series, level) | ||
if type(series) ~= "table" or #series == 0 then | |||
return nil | |||
end | |||
level = clamp(level or #series, 1, #series) | |||
local span = mw.html.create("span") | |||
span:addClass("sv-dyn") | |||
span:attr("data-series", mw.text.jsonEncode(series)) | |||
span:wikitext(mw.text.nowiki(series[level] or "")) | |||
return tostring(span) | |||
end | |||
local function seriesFromValuePair(block, maxLevel) | |||
if type(block) ~= "table" then | |||
return nil | |||
end | |||
local base = block.Base | |||
local per = block["Per Level"] | |||
local function pickUnit(v) | |||
if type(v) == "table" and v.Unit and v.Unit ~= "" then | |||
return v.Unit | |||
end | |||
return nil | |||
end | |||
local unit = pickUnit(base) or pickUnit(per) | |||
local function fmtAny(v) | |||
if type(v) == "table" and v.Value ~= nil then | |||
if v.Unit then | |||
return tostring(v.Value) .. " " .. tostring(v.Unit) | |||
end | |||
return tostring(v.Value) | |||
end | |||
return (v ~= nil) and tostring(v) or nil | |||
end | |||
local series = {} | |||
if type(per) == "table" and #per > 0 then | |||
for lv = 1, maxLevel do | |||
local raw = per[lv] or per[#per] | |||
local one = fmtAny(raw) | |||
if one == nil or isNoneLike(raw) or isNoneLike(one) or one == "0" then | |||
one = "—" | |||
end | |||
series[lv] = one | |||
end | |||
return series | |||
end | |||
if type(per) == "table" and #per == 0 then | |||
local one = fmtAny(base) | |||
if one == nil or isNoneLike(base) or isNoneLike(one) or one == "0" then | |||
one = "—" | |||
end | |||
for lv = 1, maxLevel do | |||
series[lv] = one | |||
end | |||
return series | |||
end | |||
local baseN = toNum(base) or 0 | |||
local perN = toNum(per) | |||
if perN ~= nil then | |||
for lv = 1, maxLevel do | |||
local total = baseN + (perN * lv) | |||
local v = unit and { Value = total, Unit = unit } or total | |||
local one = fmtAny(v) | |||
if one == nil or total == 0 then | |||
one = "—" | |||
end | |||
series[lv] = one | |||
end | |||
return series | |||
end | |||
local raw = (base ~= nil) and base or per | |||
local one = fmtAny(raw) | |||
if one == nil then | |||
return nil | |||
end | |||
if one == "0" or isNoneLike(raw) then | |||
one = "—" | |||
end | |||
for lv = 1, maxLevel do | |||
series[lv] = one | |||
end | |||
return series | |||
end | end | ||
local function | local function displayFromSeries(series, level) | ||
if type(series) ~= "table" or #series == 0 then | |||
return nil | |||
end | |||
local any = false | |||
for _, v in ipairs(series) do | |||
if v ~= "—" then | |||
any = true | |||
break | |||
end | |||
end | |||
if not any then | |||
return nil | |||
end | |||
if isFlatList(series) then | |||
return mw.text.nowiki(series[1]) | |||
end | |||
return dynSpan(series, level) | |||
end | end | ||
-- | ---------------------------------------------------------------------- | ||
local | -- Lookups | ||
---------------------------------------------------------------------- | |||
local function getPassiveById(id) | |||
id = trim(id) | |||
if not id then return nil end | |||
local dataset = getPassives() | |||
local byId = dataset.byId or {} | |||
return byId[id] | |||
end | |||
local function findPassiveByName(name) | |||
name = trim(name) | |||
if not name then return nil end | |||
local dataset = getPassives() | |||
for _, rec in ipairs(dataset.records or {}) do | |||
if type(rec) == "table" then | |||
local display = rec["External Name"] or rec.Name or rec["Display Name"] | |||
if display == name then | |||
return rec | |||
end | |||
end | |||
end | |||
return nil | |||
end | |||
local function resolveDisplayName(rec) | |||
if type(rec) ~= "table" then | |||
return tostring(rec or "") | |||
end | |||
return rec["External Name"] or rec.Name or rec["Display Name"] or rec["Internal Name"] or rec.ID or "Unknown Passive" | |||
end | |||
---------------------------------------------------------------------- | |||
-- User matching (for auto lists on class pages) | |||
---------------------------------------------------------------------- | |||
local function passiveMatchesUser(rec, userName) | |||
if type(rec) ~= "table" or not userName or userName == "" then | |||
return false | |||
end | |||
local users = rec.Users | |||
if type(users) ~= "table" then | |||
return false | |||
end | |||
local userLower = mw.ustring.lower(userName) | |||
local function listHas(list) | |||
if type(list) ~= "table" then | |||
return false | |||
end | |||
for _, v in ipairs(list) do | |||
if type(v) == "string" and mw.ustring.lower(v) == userLower then | |||
return true | |||
end | |||
end | |||
return false | |||
end | |||
return listHas(users.Classes) or listHas(users.Summons) or listHas(users.Monsters) or listHas(users.Events) | |||
end | end | ||
---------------------------------------------------------------------- | |||
-- Slot config | |||
---------------------------------------------------------------------- | |||
local HERO_SLOT_ASSIGNMENT = { | |||
[1] = "IconName", | |||
[2] = "Description", | |||
[3] = "LevelSelector", | |||
[4] = "PassiveEffects", | |||
} | |||
local PLUGINS = {} | |||
---------------------------------------------------------------------- | |||
-- Slot scaffolds | |||
---------------------------------------------------------------------- | |||
local function slotBox(slot, extraClasses, innerHtml, opts) | |||
opts = opts or {} | |||
local box = mw.html.create("div") | |||
box:addClass("sv-slot") | |||
box:addClass("sv-slot--" .. tostring(slot)) | |||
box:attr("data-hero-slot", tostring(slot)) | |||
if opts.isFull then | |||
box:addClass("sv-slot--full") | |||
end | |||
if extraClasses then | |||
if type(extraClasses) == "string" then | |||
box:addClass(extraClasses) | |||
elseif type(extraClasses) == "table" then | |||
for _, c in ipairs(extraClasses) do box:addClass(c) end | |||
end | |||
end | |||
if opts.isEmpty then | |||
box:addClass("sv-slot--empty") | |||
end | |||
local body = box:tag("div"):addClass("sv-slot__body") | |||
if innerHtml and innerHtml ~= "" then | |||
body:wikitext(innerHtml) | |||
end | |||
return tostring(box) | |||
end | end | ||
local function | local function normalizeResult(res) | ||
if res == nil then return nil end | |||
if type(res) == "string" then | |||
return { inner = res, classes = nil } | |||
end | |||
if type(res) == "table" then | |||
local inner = res.inner | |||
if type(inner) ~= "string" then | |||
inner = (inner ~= nil) and tostring(inner) or "" | |||
end | |||
return { inner = inner, classes = res.classes } | |||
end | |||
return { inner = tostring(res), classes = nil } | |||
end | |||
local function safeCallPlugin(name, rec, ctx) | |||
local fn = PLUGINS[name] | |||
if type(fn) ~= "function" then | |||
return nil | |||
end | |||
local ok, out = pcall(fn, rec, ctx) | |||
if not ok then | |||
return nil | |||
end | |||
return normalizeResult(out) | |||
end | |||
local function isEmptySlotContent(inner) | |||
if inner == nil then return true end | |||
local raw = tostring(inner) | |||
for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do | |||
if mw.ustring.find(raw, pat) then | |||
return false | |||
end | |||
end | |||
local trimmed = mw.text.trim(raw) | |||
if trimmed == "" or trimmed == "—" then | |||
return true | |||
end | |||
local withoutTags = mw.text.trim(mw.ustring.gsub(trimmed, "<[^>]+>", "")) | |||
return (withoutTags == "" or withoutTags == "—") | |||
end | end | ||
local function | local function renderHeroSlot(slotIndex, rec, ctx) | ||
local pluginName = HERO_SLOT_ASSIGNMENT[slotIndex] | |||
if not pluginName then | |||
return nil | |||
end | |||
local res = safeCallPlugin(pluginName, rec, ctx) | |||
if not res or isEmptySlotContent(res.inner) then | |||
return nil | |||
end | |||
return { | |||
inner = res.inner, | |||
classes = res.classes, | |||
} | |||
end | end | ||
local function | local function buildHeroSlotsUI(rec, ctx) | ||
local grid = mw.html.create("div") | |||
grid:addClass("sv-slot-grid") | |||
local slots = {} | |||
for slot = 1, 4 do | |||
slots[slot] = renderHeroSlot(slot, rec, ctx) | |||
end | |||
local hasSlots = false | |||
for _, pair in ipairs({ { 1, 2 }, { 3, 4 } }) do | |||
local left = slots[pair[1]] | |||
local right = slots[pair[2]] | |||
if left or right then | |||
hasSlots = true | |||
if left and right then | |||
grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isEmpty = false })) | |||
grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isEmpty = false })) | |||
elseif left then | |||
grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isFull = true })) | |||
elseif right then | |||
grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isFull = true })) | |||
end | |||
end | |||
end | |||
if not hasSlots then | |||
return "" | |||
end | |||
return tostring(grid) | |||
end | |||
local function addHeroSlotsRow(tbl, slotsUI) | |||
if not slotsUI or slotsUI == "" then | |||
return | |||
end | |||
local row = tbl:tag("tr") | |||
row:addClass("sv-slot-row") | |||
local cell = row:tag("td") | |||
cell:attr("colspan", 2) | |||
cell:addClass("sv-slot-cell") | |||
cell:wikitext(slotsUI) | |||
end | end | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- | -- Plug-ins | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
local | function PLUGINS.IconName(rec) | ||
local icon = rec.Icon | |||
local title = resolveDisplayName(rec) | |||
local notesList = {} | |||
if type(rec.Notes) == "table" then | |||
for _, note in ipairs(rec.Notes) do | |||
local n = trim(note) | |||
if n then | |||
table.insert(notesList, mw.text.nowiki(n)) | |||
end | |||
end | |||
elseif type(rec.Notes) == "string" then | |||
local n = trim(rec.Notes) | |||
if n then | |||
notesList = { mw.text.nowiki(n) } | |||
end | |||
end | |||
local req = rec.Requirements or {} | |||
local reqSkillsRaw = (type(req["Required Skills"]) == "table") and req["Required Skills"] or {} | |||
local reqWeaponsRaw = (type(req["Required Weapons"]) == "table") and req["Required Weapons"] or {} | |||
local reqStancesRaw = (type(req["Required Stances"]) == "table") and req["Required Stances"] or {} | |||
local reqSkills = {} | |||
for _, rs in ipairs(reqSkillsRaw) do | |||
if type(rs) == "table" then | |||
local nameReq = rs["Skill External Name"] or rs["Skill Internal Name"] or rs["Skill Name"] or rs["Skill ID"] or "Unknown" | |||
local lvlReq = rs["Required Level"] | |||
if lvlReq then | |||
table.insert(reqSkills, string.format("%s (Lv.%s)", mw.text.nowiki(nameReq), mw.text.nowiki(tostring(lvlReq)))) | |||
else | |||
table.insert(reqSkills, mw.text.nowiki(nameReq)) | |||
end | |||
end | |||
end | |||
local reqWeapons = {} | |||
for _, w in ipairs(reqWeaponsRaw) do | |||
local wn = trim(w) | |||
if wn then table.insert(reqWeapons, mw.text.nowiki(wn)) end | |||
end | |||
local reqStances = {} | |||
for _, s in ipairs(reqStancesRaw) do | |||
local sn = trim(s) | |||
if sn then table.insert(reqStances, mw.text.nowiki(sn)) end | |||
end | |||
local hasNotes = (#notesList > 0) | |||
local hasReq = (#reqSkills > 0) or (#reqWeapons > 0) or (#reqStances > 0) | |||
local wrap = mw.html.create("div") | |||
wrap:addClass("sv-herobar-1-wrap") | |||
wrap:addClass("sv-tip-scope") | |||
local iconBox = wrap:tag("div") | |||
iconBox:addClass("sv-herobar-icon") | |||
if icon and icon ~= "" then | |||
iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon)) | |||
end | |||
local textBox = wrap:tag("div") | |||
textBox:addClass("sv-herobar-text") | |||
local titleRow = textBox:tag("div") | |||
titleRow:addClass("sv-herobar-title-row") | |||
local titleBox = titleRow:tag("div") | |||
titleBox:addClass("spiritvale-infobox-title") | |||
titleBox:wikitext(title) | |||
if hasNotes then | |||
local notesBtn = mw.html.create("span") | |||
notesBtn:addClass("sv-tip-btn sv-tip-btn--notes") | |||
notesBtn:attr("role", "button") | |||
notesBtn:attr("tabindex", "0") | |||
notesBtn:attr("data-sv-tip", "notes") | |||
notesBtn:attr("aria-label", "Notes") | |||
notesBtn:attr("aria-expanded", "false") | |||
notesBtn:tag("span"):addClass("sv-ico sv-ico--info"):attr("aria-hidden", "true"):wikitext("i") | |||
titleRow:node(notesBtn) | |||
end | |||
if hasReq then | |||
local pillRow = wrap:tag("div") | |||
pillRow:addClass("sv-pill-row") | |||
pillRow:addClass("sv-pill-row--req") | |||
local pill = pillRow:tag("span") | |||
pill:addClass("sv-pill sv-pill--req sv-tip-btn") | |||
pill:attr("role", "button") | |||
pill:attr("tabindex", "0") | |||
pill:attr("data-sv-tip", "req") | |||
pill:attr("aria-label", "Requirements") | |||
pill:attr("aria-expanded", "false") | |||
pill:wikitext("Requirements") | |||
end | |||
if hasNotes then | |||
local notesContent = wrap:tag("div") | |||
notesContent:addClass("sv-tip-content") | |||
notesContent:attr("data-sv-tip-content", "notes") | |||
notesContent:tag("div"):addClass("sv-tip-title"):wikitext("Notes") | |||
notesContent:tag("div"):wikitext(table.concat(notesList, "<br />")) | |||
end | |||
if hasReq then | |||
local reqContent = wrap:tag("div") | |||
reqContent:addClass("sv-tip-content") | |||
reqContent:attr("data-sv-tip-content", "req") | |||
if #reqSkills > 0 then | |||
local section = reqContent:tag("div") | |||
section:addClass("sv-tip-section") | |||
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Skills") | |||
section:tag("div"):wikitext(table.concat(reqSkills, "<br />")) | |||
end | |||
if #reqWeapons > 0 then | |||
local section = reqContent:tag("div") | |||
section:addClass("sv-tip-section") | |||
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Weapons") | |||
section:tag("div"):wikitext(table.concat(reqWeapons, ", ")) | |||
end | |||
if #reqStances > 0 then | |||
local section = reqContent:tag("div") | |||
section:addClass("sv-tip-section") | |||
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Stances") | |||
section:tag("div"):wikitext(table.concat(reqStances, ", ")) | |||
end | |||
end | |||
return { | |||
inner = tostring(wrap), | |||
classes = "module-icon-name", | |||
} | |||
end | |||
function PLUGINS.Description(rec) | |||
local desc = trim(rec.Description) | |||
if not desc then | |||
return nil | |||
end | |||
local body = mw.html.create("div") | |||
body:addClass("sv-description") | |||
body:wikitext(string.format("''%s''", desc)) | |||
return { | |||
inner = tostring(body), | |||
classes = "module-description", | |||
} | |||
end | end | ||
------ | function PLUGINS.LevelSelector(rec, ctx) | ||
local level = ctx.level or 1 | |||
-- | local maxLevel = ctx.maxLevel or 1 | ||
local inner = mw.html.create("div") | |||
inner:addClass("sv-level-ui") | |||
inner:tag("div") | |||
:addClass("sv-level-label") | |||
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel)) | |||
local slider = inner:tag("div"):addClass("sv-level-slider") | |||
if tonumber(maxLevel) and tonumber(maxLevel) > 1 then | |||
slider:tag("input") | |||
:attr("type", "range") | |||
:attr("min", "1") | |||
:attr("max", tostring(maxLevel)) | |||
:attr("value", tostring(level)) | |||
:addClass("sv-level-range") | |||
:attr("aria-label", "Skill level select") | |||
else | |||
inner:addClass("sv-level-ui-single") | |||
slider:addClass("sv-level-slider-single") | |||
end | |||
return { | |||
inner = tostring(inner), | |||
classes = "module-level-selector", | |||
} | |||
end | |||
function PLUGINS.PassiveEffects(rec, ctx) | |||
local effects = rec["Passive Effects"] | |||
if type(effects) ~= "table" or #effects == 0 then | |||
return nil | |||
end | |||
local root = mw.html.create("div") | |||
root:addClass("sv-passive-effects") | |||
root:addClass("sv-compact-root") | |||
local tbl = root:tag("div") | |||
tbl:addClass("sv-pe-table") | |||
local added = false | |||
local maxLevel = ctx.maxLevel or 1 | |||
local level = ctx.level or 1 | |||
for _, eff in ipairs(effects) do | |||
if type(eff) == "table" then | |||
local t = eff.Type or {} | |||
local label = t.Name or t.ID or eff.ID or "Unknown" | |||
local valueBlock = eff.Value | |||
local display | |||
if type(valueBlock) == "table" and (valueBlock.Base ~= nil or valueBlock["Per Level"] ~= nil or type(valueBlock["Per Level"]) == "table") then | |||
display = displayFromSeries(seriesFromValuePair(valueBlock, maxLevel), level) | |||
elseif valueBlock ~= nil then | |||
display = mw.text.nowiki(tostring(valueBlock)) | |||
end | |||
if not display and type(valueBlock) == "table" then | |||
local expr = valueBlock.Expression or valueBlock.expression | |||
if expr and trim(tostring(expr)) then | |||
display = mw.text.nowiki(tostring(expr)) | |||
end | |||
end | |||
if not display then | |||
local expr = eff.Expression or eff.expression | |||
if expr and trim(tostring(expr)) then | |||
display = mw.text.nowiki(tostring(expr)) | |||
end | |||
end | |||
local qual = eff.Weapon or eff["Weapon"] or eff["Weapon Type"] or eff.Stance or eff["Stance"] or eff["Stance Type"] | |||
if display and qual and trim(tostring(qual)) then | |||
display = tostring(display) .. " (" .. mw.text.nowiki(tostring(qual)) .. ")" | |||
end | |||
if display then | |||
added = true | |||
local row = tbl:tag("div") | |||
row:addClass("sv-pe-row") | |||
row:tag("div"):addClass("sv-pe-label"):wikitext(mw.text.nowiki(label)) | |||
row:tag("div"):addClass("sv-pe-value"):wikitext(display) | |||
end | |||
end | |||
end | |||
if not added then | |||
return nil | |||
end | |||
return { | |||
inner = tostring(root), | |||
classes = "module-passive-effects", | |||
} | |||
end | |||
---------------------------------------------------------------------- | |||
-- Infobox builder | |||
---------------------------------------------------------------------- | |||
local function buildInfobox(rec, opts) | |||
opts = opts or {} | |||
local maxLevel = tonumber(rec["Max Level"]) or 1 | |||
if maxLevel < 1 then maxLevel = 1 end | |||
local level = clamp(maxLevel, 1, maxLevel) | |||
local ctx = { | |||
maxLevel = maxLevel, | |||
level = level, | |||
} | |||
local root = mw.html.create("table") | |||
root:addClass("spiritvale-passive-infobox") | |||
root:addClass("sv-passive-card") | |||
root:addClass("sv-slot-card") | |||
root:attr("data-max-level", tostring(maxLevel)) | |||
root:attr("data-level", tostring(level)) | |||
if opts.inList then | |||
root:addClass("sv-passive-inlist") | |||
end | |||
local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID) | |||
if internalId then | |||
root:attr("data-passive-id", internalId) | |||
end | |||
addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx)) | |||
return tostring(root) | |||
end | end | ||
| Line 465: | Line 767: | ||
function p.listForUser(frame) | function p.listForUser(frame) | ||
local args = getArgs(frame) | |||
local userName = args.user or args[1] | |||
if not userName or userName == "" then | |||
userName = mw.title.getCurrentTitle().text | |||
end | |||
if not userName or userName == "" then | |||
return "<strong>No user name provided to Passive list.</strong>" | |||
end | |||
local dataset = getPassives() | |||
local matches = {} | |||
for _, rec in ipairs(dataset.records or {}) do | |||
if passiveMatchesUser(rec, userName) then | |||
table.insert(matches, rec) | |||
end | |||
end | |||
if #matches == 0 then | |||
return string.format( | |||
"<strong>No passives found for:</strong> %s", | |||
mw.text.nowiki(userName) | |||
) | |||
end | |||
local parts = {} | |||
for _, rec in ipairs(matches) do | |||
local title = resolveDisplayName(rec) | |||
table.insert(parts, string.format("=== %s ===\n%s", title, buildInfobox(rec, { inList = true }))) | |||
end | |||
return table.concat(parts, "\n") | |||
end | end | ||
| Line 508: | Line 808: | ||
function p.infobox(frame) | function p.infobox(frame) | ||
local args = getArgs(frame) | |||
local raw1 = args[1] | |||
local name = args.name or raw1 | |||
local id = args.id | |||
local rec | |||
if name and name ~= "" then | |||
rec = findPassiveByName(name) | |||
end | |||
if not rec and id and id ~= "" then | |||
rec = getPassiveById(id) | |||
end | |||
if not rec then | |||
local pageTitle = mw.title.getCurrentTitle() | |||
local pageName = pageTitle and pageTitle.text or "" | |||
local noExplicitArgs = | |||
(not raw1 or raw1 == "") and | |||
(not args.name or args.name == "") and | |||
(not id or id == "") | |||
if noExplicitArgs then | |||
return p.listForUser(frame) | |||
end | |||
if name and name ~= "" and name == pageName and (not id or id == "") then | |||
return p.listForUser(frame) | |||
end | |||
local label = name or id or "?" | |||
return string.format( | |||
"<strong>Unknown passive:</strong> %s[[Category:Pages with unknown passive|%s]]", | |||
mw.text.nowiki(label), | |||
label | |||
) | |||
end | |||
return buildInfobox(rec) | |||
end | end | ||
return p | return p | ||
Latest revision as of 19:18, 21 December 2025
Module:GamePassives
Module:GamePassives renders passive skill data from Data:passives.json into a reusable infobox-style table.
It is intended to be used via a template (for example Template:Passive) so that passives can be embedded on any page without creating individual pages for each passive.
This module:
- Loads data via Module:GameData →
GameData.loadPassives(). - Looks up passives primarily by display
"Name"(what editors use), with"Internal Name"as an optional fallback. - Builds a table with only the fields that actually exist for that passive.
Data source
Passive data comes from Data:passives.json, which is a JSON page with this top-level structure (see Module:GameData/doc for full details):
{
"version": "SpiritVale-0.9.3",
"schema_version": 1,
"generated_at": "2025-12-12T17:24:05.784284+00:00",
"records": [
{
"Name": "Honed Blade",
"Internal Name": "CritMastery",
"...": "other fields specific to passives"
}
]
}
Each record is a single passive. Important keys:
"Name"– the display name (what players and editors will usually see and use)."Internal Name"– the stable ID used internally and available as an optional parameter for power users and tooling.
Common fields include:
IconDescriptionMax LevelUsers(e.g. Classes)RequirementsPassive EffectsStatus ApplicationsNotes
Output
For a given passive, the module renders a table with the CSS class spiritvale-passive-infobox.
Depending on what exists in the JSON record, the table may include:
- Header row with passive name (and icon, if present).
- Description.
- Max level.
- Users:
- Classes
- Summons
- Monsters
- Events
- Requirements:
- Required Skills (with required level)
- Required Weapons
- Required Stances
- Passive effects:
- Each entry from
"Passive Effects"(Type name, optional Expression, Base and Per Level where present)
- Each entry from
- Status interactions:
- Status Applications (if any)
- Notes:
- Any entries from
"Notes"(one per line)
- Any entries from
Rows are only shown if the underlying field exists in the JSON for that passive.
Public interface
The module exposes a single entry point for templates:
GamePassives.infobox(frame)
This is usually called via #invoke from a template, not directly from pages.
It accepts the following parameters (either passed directly or via a wrapper template):
1– unnamed parameter; treated as the passive"Name".name– explicit display"Name"of the passive (equivalent to1).id–"Internal Name"of the passive (optional fallback / power use).
Lookup order:
- If
nameor the first unnamed parameter is provided and matches a record’s"Name", that record is used. - Otherwise, if
idis provided and matches an"Internal Name", that record is used. - If nothing is found, a small error message is returned and the page is categorized for tracking.
Example direct usage (not recommended; normally use a template):
No passives found for: GamePassives
or:
No passives found for: GamePassives
Template:Passive
The recommended way to use this module is via a small wrapper template, for example:
Template:Passive
No passives found for: GamePassives
Typical usage on any page:
A life of combat has perfected your edge, each critical strike hits with ruthless efficiency. Level 10 / 10 Critical Damage 30% |
or, explicitly:
A life of combat has perfected your edge, each critical strike hits with ruthless efficiency. Level 10 / 10 Critical Damage 30% |
Internal IDs can still be used when needed:
A life of combat has perfected your edge, each critical strike hits with ruthless efficiency. Level 10 / 10 Critical Damage 30% |
This keeps page wikitext simple while centralizing all JSON loading and formatting logic inside Lua.
-- Module:GamePassives
--
-- Phase 6.5+ Slot/Grid architecture (aligned with Module:GameSkills).
--
-- Layout:
-- Row 1: Slot 1 + Slot 2 (IconName + Description)
-- Row 2: Slot 3 + Slot 4 (LevelSelector + PassiveEffects)
--
-- Requires Common.js:
-- - updates .sv-dyn spans via data-series
-- - updates .sv-level-num + data-level on .sv-passive-card
-- - binds to input.sv-level-range inside each card
--
-- Usage (single passive):
-- {{Passive|Honed Blade}}
-- {{Passive|name=Honed Blade}}
-- {{Passive|id=CritMastery}}
--
-- Usage (auto-list on class page, e.g. "Acolyte"):
-- {{Passive}} -> lists all Acolyte passives (page name)
-- {{Passive|Acolyte}} -> same, if no passive literally called "Acolyte"
local GameData = require("Module:GameData")
local p = {}
----------------------------------------------------------------------
-- Data cache
----------------------------------------------------------------------
local passivesCache
local function getPassives()
if not passivesCache then
passivesCache = GameData.loadPassives()
end
return passivesCache
end
----------------------------------------------------------------------
-- Small utilities
----------------------------------------------------------------------
local function getArgs(frame)
local parent = frame:getParent()
return (parent and parent.args) or frame.args
end
local function trim(s)
if type(s) ~= "string" then
return nil
end
s = mw.text.trim(s)
return (s ~= "" and s) or nil
end
local function toNum(v)
if type(v) == "number" then
return v
end
if type(v) == "string" then
return tonumber(v)
end
if type(v) == "table" and v.Value ~= nil then
return toNum(v.Value)
end
return nil
end
local function clamp(n, lo, hi)
if type(n) ~= "number" then
return lo
end
if n < lo then return lo end
if n > hi then return hi end
return n
end
local function fmtNum(n)
if type(n) ~= "number" then
return (n ~= nil) and tostring(n) or nil
end
if math.abs(n - math.floor(n)) < 1e-9 then
return tostring(math.floor(n))
end
local s = string.format("%.4f", n)
s = mw.ustring.gsub(s, "0+$", "")
s = mw.ustring.gsub(s, "%.$", "")
return s
end
local function isNoneLike(v)
if v == nil then return true end
local s = mw.text.trim(tostring(v))
if s == "" then return true end
s = mw.ustring.lower(s)
return (s == "none" or s == "no" or s == "n/a" or s == "na" or s == "null")
end
local function isFlatList(list)
if type(list) ~= "table" or #list == 0 then
return false
end
local first = tostring(list[1])
for i = 2, #list do
if tostring(list[i]) ~= first then
return false
end
end
return true
end
----------------------------------------------------------------------
-- Dynamic spans (JS-driven)
----------------------------------------------------------------------
local function dynSpan(series, level)
if type(series) ~= "table" or #series == 0 then
return nil
end
level = clamp(level or #series, 1, #series)
local span = mw.html.create("span")
span:addClass("sv-dyn")
span:attr("data-series", mw.text.jsonEncode(series))
span:wikitext(mw.text.nowiki(series[level] or ""))
return tostring(span)
end
local function seriesFromValuePair(block, maxLevel)
if type(block) ~= "table" then
return nil
end
local base = block.Base
local per = block["Per Level"]
local function pickUnit(v)
if type(v) == "table" and v.Unit and v.Unit ~= "" then
return v.Unit
end
return nil
end
local unit = pickUnit(base) or pickUnit(per)
local function fmtAny(v)
if type(v) == "table" and v.Value ~= nil then
if v.Unit then
return tostring(v.Value) .. " " .. tostring(v.Unit)
end
return tostring(v.Value)
end
return (v ~= nil) and tostring(v) or nil
end
local series = {}
if type(per) == "table" and #per > 0 then
for lv = 1, maxLevel do
local raw = per[lv] or per[#per]
local one = fmtAny(raw)
if one == nil or isNoneLike(raw) or isNoneLike(one) or one == "0" then
one = "—"
end
series[lv] = one
end
return series
end
if type(per) == "table" and #per == 0 then
local one = fmtAny(base)
if one == nil or isNoneLike(base) or isNoneLike(one) or one == "0" then
one = "—"
end
for lv = 1, maxLevel do
series[lv] = one
end
return series
end
local baseN = toNum(base) or 0
local perN = toNum(per)
if perN ~= nil then
for lv = 1, maxLevel do
local total = baseN + (perN * lv)
local v = unit and { Value = total, Unit = unit } or total
local one = fmtAny(v)
if one == nil or total == 0 then
one = "—"
end
series[lv] = one
end
return series
end
local raw = (base ~= nil) and base or per
local one = fmtAny(raw)
if one == nil then
return nil
end
if one == "0" or isNoneLike(raw) then
one = "—"
end
for lv = 1, maxLevel do
series[lv] = one
end
return series
end
local function displayFromSeries(series, level)
if type(series) ~= "table" or #series == 0 then
return nil
end
local any = false
for _, v in ipairs(series) do
if v ~= "—" then
any = true
break
end
end
if not any then
return nil
end
if isFlatList(series) then
return mw.text.nowiki(series[1])
end
return dynSpan(series, level)
end
----------------------------------------------------------------------
-- Lookups
----------------------------------------------------------------------
local function getPassiveById(id)
id = trim(id)
if not id then return nil end
local dataset = getPassives()
local byId = dataset.byId or {}
return byId[id]
end
local function findPassiveByName(name)
name = trim(name)
if not name then return nil end
local dataset = getPassives()
for _, rec in ipairs(dataset.records or {}) do
if type(rec) == "table" then
local display = rec["External Name"] or rec.Name or rec["Display Name"]
if display == name then
return rec
end
end
end
return nil
end
local function resolveDisplayName(rec)
if type(rec) ~= "table" then
return tostring(rec or "")
end
return rec["External Name"] or rec.Name or rec["Display Name"] or rec["Internal Name"] or rec.ID or "Unknown Passive"
end
----------------------------------------------------------------------
-- User matching (for auto lists on class pages)
----------------------------------------------------------------------
local function passiveMatchesUser(rec, userName)
if type(rec) ~= "table" or not userName or userName == "" then
return false
end
local users = rec.Users
if type(users) ~= "table" then
return false
end
local userLower = mw.ustring.lower(userName)
local function listHas(list)
if type(list) ~= "table" then
return false
end
for _, v in ipairs(list) do
if type(v) == "string" and mw.ustring.lower(v) == userLower then
return true
end
end
return false
end
return listHas(users.Classes) or listHas(users.Summons) or listHas(users.Monsters) or listHas(users.Events)
end
----------------------------------------------------------------------
-- Slot config
----------------------------------------------------------------------
local HERO_SLOT_ASSIGNMENT = {
[1] = "IconName",
[2] = "Description",
[3] = "LevelSelector",
[4] = "PassiveEffects",
}
local PLUGINS = {}
----------------------------------------------------------------------
-- Slot scaffolds
----------------------------------------------------------------------
local function slotBox(slot, extraClasses, innerHtml, opts)
opts = opts or {}
local box = mw.html.create("div")
box:addClass("sv-slot")
box:addClass("sv-slot--" .. tostring(slot))
box:attr("data-hero-slot", tostring(slot))
if opts.isFull then
box:addClass("sv-slot--full")
end
if extraClasses then
if type(extraClasses) == "string" then
box:addClass(extraClasses)
elseif type(extraClasses) == "table" then
for _, c in ipairs(extraClasses) do box:addClass(c) end
end
end
if opts.isEmpty then
box:addClass("sv-slot--empty")
end
local body = box:tag("div"):addClass("sv-slot__body")
if innerHtml and innerHtml ~= "" then
body:wikitext(innerHtml)
end
return tostring(box)
end
local function normalizeResult(res)
if res == nil then return nil end
if type(res) == "string" then
return { inner = res, classes = nil }
end
if type(res) == "table" then
local inner = res.inner
if type(inner) ~= "string" then
inner = (inner ~= nil) and tostring(inner) or ""
end
return { inner = inner, classes = res.classes }
end
return { inner = tostring(res), classes = nil }
end
local function safeCallPlugin(name, rec, ctx)
local fn = PLUGINS[name]
if type(fn) ~= "function" then
return nil
end
local ok, out = pcall(fn, rec, ctx)
if not ok then
return nil
end
return normalizeResult(out)
end
local function isEmptySlotContent(inner)
if inner == nil then return true end
local raw = tostring(inner)
for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do
if mw.ustring.find(raw, pat) then
return false
end
end
local trimmed = mw.text.trim(raw)
if trimmed == "" or trimmed == "—" then
return true
end
local withoutTags = mw.text.trim(mw.ustring.gsub(trimmed, "<[^>]+>", ""))
return (withoutTags == "" or withoutTags == "—")
end
local function renderHeroSlot(slotIndex, rec, ctx)
local pluginName = HERO_SLOT_ASSIGNMENT[slotIndex]
if not pluginName then
return nil
end
local res = safeCallPlugin(pluginName, rec, ctx)
if not res or isEmptySlotContent(res.inner) then
return nil
end
return {
inner = res.inner,
classes = res.classes,
}
end
local function buildHeroSlotsUI(rec, ctx)
local grid = mw.html.create("div")
grid:addClass("sv-slot-grid")
local slots = {}
for slot = 1, 4 do
slots[slot] = renderHeroSlot(slot, rec, ctx)
end
local hasSlots = false
for _, pair in ipairs({ { 1, 2 }, { 3, 4 } }) do
local left = slots[pair[1]]
local right = slots[pair[2]]
if left or right then
hasSlots = true
if left and right then
grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isEmpty = false }))
grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isEmpty = false }))
elseif left then
grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isFull = true }))
elseif right then
grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isFull = true }))
end
end
end
if not hasSlots then
return ""
end
return tostring(grid)
end
local function addHeroSlotsRow(tbl, slotsUI)
if not slotsUI or slotsUI == "" then
return
end
local row = tbl:tag("tr")
row:addClass("sv-slot-row")
local cell = row:tag("td")
cell:attr("colspan", 2)
cell:addClass("sv-slot-cell")
cell:wikitext(slotsUI)
end
----------------------------------------------------------------------
-- Plug-ins
----------------------------------------------------------------------
function PLUGINS.IconName(rec)
local icon = rec.Icon
local title = resolveDisplayName(rec)
local notesList = {}
if type(rec.Notes) == "table" then
for _, note in ipairs(rec.Notes) do
local n = trim(note)
if n then
table.insert(notesList, mw.text.nowiki(n))
end
end
elseif type(rec.Notes) == "string" then
local n = trim(rec.Notes)
if n then
notesList = { mw.text.nowiki(n) }
end
end
local req = rec.Requirements or {}
local reqSkillsRaw = (type(req["Required Skills"]) == "table") and req["Required Skills"] or {}
local reqWeaponsRaw = (type(req["Required Weapons"]) == "table") and req["Required Weapons"] or {}
local reqStancesRaw = (type(req["Required Stances"]) == "table") and req["Required Stances"] or {}
local reqSkills = {}
for _, rs in ipairs(reqSkillsRaw) do
if type(rs) == "table" then
local nameReq = rs["Skill External Name"] or rs["Skill Internal Name"] or rs["Skill Name"] or rs["Skill ID"] or "Unknown"
local lvlReq = rs["Required Level"]
if lvlReq then
table.insert(reqSkills, string.format("%s (Lv.%s)", mw.text.nowiki(nameReq), mw.text.nowiki(tostring(lvlReq))))
else
table.insert(reqSkills, mw.text.nowiki(nameReq))
end
end
end
local reqWeapons = {}
for _, w in ipairs(reqWeaponsRaw) do
local wn = trim(w)
if wn then table.insert(reqWeapons, mw.text.nowiki(wn)) end
end
local reqStances = {}
for _, s in ipairs(reqStancesRaw) do
local sn = trim(s)
if sn then table.insert(reqStances, mw.text.nowiki(sn)) end
end
local hasNotes = (#notesList > 0)
local hasReq = (#reqSkills > 0) or (#reqWeapons > 0) or (#reqStances > 0)
local wrap = mw.html.create("div")
wrap:addClass("sv-herobar-1-wrap")
wrap:addClass("sv-tip-scope")
local iconBox = wrap:tag("div")
iconBox:addClass("sv-herobar-icon")
if icon and icon ~= "" then
iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon))
end
local textBox = wrap:tag("div")
textBox:addClass("sv-herobar-text")
local titleRow = textBox:tag("div")
titleRow:addClass("sv-herobar-title-row")
local titleBox = titleRow:tag("div")
titleBox:addClass("spiritvale-infobox-title")
titleBox:wikitext(title)
if hasNotes then
local notesBtn = mw.html.create("span")
notesBtn:addClass("sv-tip-btn sv-tip-btn--notes")
notesBtn:attr("role", "button")
notesBtn:attr("tabindex", "0")
notesBtn:attr("data-sv-tip", "notes")
notesBtn:attr("aria-label", "Notes")
notesBtn:attr("aria-expanded", "false")
notesBtn:tag("span"):addClass("sv-ico sv-ico--info"):attr("aria-hidden", "true"):wikitext("i")
titleRow:node(notesBtn)
end
if hasReq then
local pillRow = wrap:tag("div")
pillRow:addClass("sv-pill-row")
pillRow:addClass("sv-pill-row--req")
local pill = pillRow:tag("span")
pill:addClass("sv-pill sv-pill--req sv-tip-btn")
pill:attr("role", "button")
pill:attr("tabindex", "0")
pill:attr("data-sv-tip", "req")
pill:attr("aria-label", "Requirements")
pill:attr("aria-expanded", "false")
pill:wikitext("Requirements")
end
if hasNotes then
local notesContent = wrap:tag("div")
notesContent:addClass("sv-tip-content")
notesContent:attr("data-sv-tip-content", "notes")
notesContent:tag("div"):addClass("sv-tip-title"):wikitext("Notes")
notesContent:tag("div"):wikitext(table.concat(notesList, "<br />"))
end
if hasReq then
local reqContent = wrap:tag("div")
reqContent:addClass("sv-tip-content")
reqContent:attr("data-sv-tip-content", "req")
if #reqSkills > 0 then
local section = reqContent:tag("div")
section:addClass("sv-tip-section")
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Skills")
section:tag("div"):wikitext(table.concat(reqSkills, "<br />"))
end
if #reqWeapons > 0 then
local section = reqContent:tag("div")
section:addClass("sv-tip-section")
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Weapons")
section:tag("div"):wikitext(table.concat(reqWeapons, ", "))
end
if #reqStances > 0 then
local section = reqContent:tag("div")
section:addClass("sv-tip-section")
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Stances")
section:tag("div"):wikitext(table.concat(reqStances, ", "))
end
end
return {
inner = tostring(wrap),
classes = "module-icon-name",
}
end
function PLUGINS.Description(rec)
local desc = trim(rec.Description)
if not desc then
return nil
end
local body = mw.html.create("div")
body:addClass("sv-description")
body:wikitext(string.format("''%s''", desc))
return {
inner = tostring(body),
classes = "module-description",
}
end
function PLUGINS.LevelSelector(rec, ctx)
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1
local inner = mw.html.create("div")
inner:addClass("sv-level-ui")
inner:tag("div")
:addClass("sv-level-label")
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))
local slider = inner:tag("div"):addClass("sv-level-slider")
if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
slider:tag("input")
:attr("type", "range")
:attr("min", "1")
:attr("max", tostring(maxLevel))
:attr("value", tostring(level))
:addClass("sv-level-range")
:attr("aria-label", "Skill level select")
else
inner:addClass("sv-level-ui-single")
slider:addClass("sv-level-slider-single")
end
return {
inner = tostring(inner),
classes = "module-level-selector",
}
end
function PLUGINS.PassiveEffects(rec, ctx)
local effects = rec["Passive Effects"]
if type(effects) ~= "table" or #effects == 0 then
return nil
end
local root = mw.html.create("div")
root:addClass("sv-passive-effects")
root:addClass("sv-compact-root")
local tbl = root:tag("div")
tbl:addClass("sv-pe-table")
local added = false
local maxLevel = ctx.maxLevel or 1
local level = ctx.level or 1
for _, eff in ipairs(effects) do
if type(eff) == "table" then
local t = eff.Type or {}
local label = t.Name or t.ID or eff.ID or "Unknown"
local valueBlock = eff.Value
local display
if type(valueBlock) == "table" and (valueBlock.Base ~= nil or valueBlock["Per Level"] ~= nil or type(valueBlock["Per Level"]) == "table") then
display = displayFromSeries(seriesFromValuePair(valueBlock, maxLevel), level)
elseif valueBlock ~= nil then
display = mw.text.nowiki(tostring(valueBlock))
end
if not display and type(valueBlock) == "table" then
local expr = valueBlock.Expression or valueBlock.expression
if expr and trim(tostring(expr)) then
display = mw.text.nowiki(tostring(expr))
end
end
if not display then
local expr = eff.Expression or eff.expression
if expr and trim(tostring(expr)) then
display = mw.text.nowiki(tostring(expr))
end
end
local qual = eff.Weapon or eff["Weapon"] or eff["Weapon Type"] or eff.Stance or eff["Stance"] or eff["Stance Type"]
if display and qual and trim(tostring(qual)) then
display = tostring(display) .. " (" .. mw.text.nowiki(tostring(qual)) .. ")"
end
if display then
added = true
local row = tbl:tag("div")
row:addClass("sv-pe-row")
row:tag("div"):addClass("sv-pe-label"):wikitext(mw.text.nowiki(label))
row:tag("div"):addClass("sv-pe-value"):wikitext(display)
end
end
end
if not added then
return nil
end
return {
inner = tostring(root),
classes = "module-passive-effects",
}
end
----------------------------------------------------------------------
-- Infobox builder
----------------------------------------------------------------------
local function buildInfobox(rec, opts)
opts = opts or {}
local maxLevel = tonumber(rec["Max Level"]) or 1
if maxLevel < 1 then maxLevel = 1 end
local level = clamp(maxLevel, 1, maxLevel)
local ctx = {
maxLevel = maxLevel,
level = level,
}
local root = mw.html.create("table")
root:addClass("spiritvale-passive-infobox")
root:addClass("sv-passive-card")
root:addClass("sv-slot-card")
root:attr("data-max-level", tostring(maxLevel))
root:attr("data-level", tostring(level))
if opts.inList then
root:addClass("sv-passive-inlist")
end
local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
if internalId then
root:attr("data-passive-id", internalId)
end
addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx))
return tostring(root)
end
----------------------------------------------------------------------
-- Public: list all passives for a given user/class
----------------------------------------------------------------------
function p.listForUser(frame)
local args = getArgs(frame)
local userName = args.user or args[1]
if not userName or userName == "" then
userName = mw.title.getCurrentTitle().text
end
if not userName or userName == "" then
return "<strong>No user name provided to Passive list.</strong>"
end
local dataset = getPassives()
local matches = {}
for _, rec in ipairs(dataset.records or {}) do
if passiveMatchesUser(rec, userName) then
table.insert(matches, rec)
end
end
if #matches == 0 then
return string.format(
"<strong>No passives found for:</strong> %s",
mw.text.nowiki(userName)
)
end
local parts = {}
for _, rec in ipairs(matches) do
local title = resolveDisplayName(rec)
table.insert(parts, string.format("=== %s ===\n%s", title, buildInfobox(rec, { inList = true })))
end
return table.concat(parts, "\n")
end
----------------------------------------------------------------------
-- Public: single-passive or auto-list dispatcher
----------------------------------------------------------------------
function p.infobox(frame)
local args = getArgs(frame)
local raw1 = args[1]
local name = args.name or raw1
local id = args.id
local rec
if name and name ~= "" then
rec = findPassiveByName(name)
end
if not rec and id and id ~= "" then
rec = getPassiveById(id)
end
if not rec then
local pageTitle = mw.title.getCurrentTitle()
local pageName = pageTitle and pageTitle.text or ""
local noExplicitArgs =
(not raw1 or raw1 == "") and
(not args.name or args.name == "") and
(not id or id == "")
if noExplicitArgs then
return p.listForUser(frame)
end
if name and name ~= "" and name == pageName and (not id or id == "") then
return p.listForUser(frame)
end
local label = name or id or "?"
return string.format(
"<strong>Unknown passive:</strong> %s[[Category:Pages with unknown passive|%s]]",
mw.text.nowiki(label),
label
)
end
return buildInfobox(rec)
end
return p