Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Join the Playtest on Steam Now: SpiritVale

Module:GamePassives: Difference between revisions

From SpiritVale Wiki
No edit summary
No edit summary
 
(2 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:GamePassives
-- Module:GamePassives
--
--
-- Renders passive skill data (from Data:passives.json) into an
-- Phase 6.5+ Slot/Grid architecture (aligned with Module:GameSkills).
-- infobox-style table and can also list all passives for a given user/class.
--
-- 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:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Internal helpers
-- Data cache
----------------------------------------------------------------------
----------------------------------------------------------------------


Line 24: Line 32:


local function getPassives()
local function getPassives()
if not passivesCache then
        if not passivesCache then
passivesCache = GameData.loadPassives()
                passivesCache = GameData.loadPassives()
end
        end
return passivesCache
        return passivesCache
end
end
----------------------------------------------------------------------
-- Small utilities
----------------------------------------------------------------------


local function getArgs(frame)
local function getArgs(frame)
local parent = frame:getParent()
        local parent = frame:getParent()
if parent then
        return (parent and parent.args) or frame.args
return parent.args
end
return frame.args
end
end


local function listToText(list, sep)
local function trim(s)
if type(list) ~= "table" or #list == 0 then
        if type(s) ~= "string" then
return nil
                return nil
end
        end
return table.concat(list, sep or ", ")
        s = mw.text.trim(s)
        return (s ~= "" and s) or nil
end
end


-- Tag body rows so we can style/center without touching the hero rows
local function toNum(v)
local function addRow(tbl, label, value)
        if type(v) == "number" then
if value == nil or value == "" then
                return v
return
        end
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 row = tbl:tag("tr")
local function clamp(n, lo, hi)
row:addClass("spiritvale-passive-body-row")
        if type(n) ~= "number" then
row:tag("th"):wikitext(label):done()
                return lo
row:tag("td"):wikitext(value):done()
        end
        if n < lo then return lo end
        if n > hi then return hi end
        return n
end
end


-- Tag section header rows as body rows too (for centering)
local function fmtNum(n)
local function addSectionHeader(tbl, label)
        if type(n) ~= "number" then
local row = tbl:tag("tr")
                return (n ~= nil) and tostring(n) or nil
row:addClass("spiritvale-passive-body-row")
        end


local cell = row:tag("th")
        if math.abs(n - math.floor(n)) < 1e-9 then
cell:attr("colspan", 2)
                return tostring(math.floor(n))
cell:addClass("spiritvale-infobox-section-header")
        end
cell:wikitext(label)
 
        local s = string.format("%.4f", n)
        s = mw.ustring.gsub(s, "0+$", "")
        s = mw.ustring.gsub(s, "%.$", "")
        return s
end
end


-- Lookup by Internal Name
local function isNoneLike(v)
local function getPassiveById(id)
        if v == nil then return true end
if not id or id == "" then
        local s = mw.text.trim(tostring(v))
return nil
        if s == "" then return true end
end
        s = mw.ustring.lower(s)
local dataset = getPassives()
        return (s == "none" or s == "no" or s == "n/a" or s == "na" or s == "null")
local byId = dataset.byId or {}
return byId[id]
end
end


-- Lookup by display Name (for editors)
local function isFlatList(list)
local function findPassiveByName(name)
        if type(list) ~= "table" or #list == 0 then
if not name or name == "" then
                return false
return nil
        end
end
        local first = tostring(list[1])
local dataset = getPassives()
        for i = 2, #list do
for _, rec in ipairs(dataset.records or {}) do
                if tostring(list[i]) ~= first then
if rec["Name"] == name then
                        return false
return rec
                end
end
        end
end
        return true
return nil
end
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Formatting helpers
-- Dynamic spans (JS-driven)
----------------------------------------------------------------------
----------------------------------------------------------------------


local function asUl(items)
local function dynSpan(series, level)
if type(items) ~= "table" or #items == 0 then
        if type(series) ~= "table" or #series == 0 then
return nil
                return nil
end
        end
return '<ul class="spiritvale-infobox-list"><li>'
 
.. table.concat(items, "</li><li>")
        level = clamp(level or #series, 1, #series)
.. "</li></ul>"
 
        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 formatBasePer(block)
local function displayFromSeries(series, level)
if type(block) ~= "table" then
        if type(series) ~= "table" or #series == 0 then
return nil
                return nil
end
        end
local parts = {}
 
if block.Base ~= nil then
        local any = false
table.insert(parts, string.format("Base %s", tostring(block.Base)))
        for _, v in ipairs(series) do
end
                if v ~= "—" then
if block["Per Level"] ~= nil then
                        any = true
table.insert(parts, string.format("%s / Lv", tostring(block["Per Level"])))
                        break
end
                end
if #parts == 0 then
        end
return nil
        if not any then
end
                return nil
return table.concat(parts, ", ")
        end
 
        if isFlatList(series) then
                return mw.text.nowiki(series[1])
        end
        return dynSpan(series, level)
end
end


-- Passive Effects: return rows (label/value), not a single text blob
----------------------------------------------------------------------
local function passiveEffectRows(list)
-- Lookups
if type(list) ~= "table" or #list == 0 then
----------------------------------------------------------------------
return {}
 
end
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


local rows = {}
        return nil
end


for _, eff in ipairs(list) do
local function resolveDisplayName(rec)
if type(eff) == "table" then
        if type(rec) ~= "table" then
local t = eff.Type or {}
                return tostring(rec or "")
local name = t.Name or eff.ID or "Unknown"
        end
        return rec["External Name"] or rec.Name or rec["Display Name"] or rec["Internal Name"] or rec.ID or "Unknown Passive"
end


local value = eff.Value or {}
----------------------------------------------------------------------
local detail = {}
-- User matching (for auto lists on class pages)
----------------------------------------------------------------------


if value.Base ~= nil then
local function passiveMatchesUser(rec, userName)
table.insert(detail, string.format("Base %s", tostring(value.Base)))
        if type(rec) ~= "table" or not userName or userName == "" then
end
                return false
if value["Per Level"] ~= nil then
        end
table.insert(detail, string.format("%s / Lv", tostring(value["Per Level"])))
end
if value.Expression ~= nil and value.Expression ~= "" then
table.insert(detail, tostring(value.Expression))
end


-- Optional qualifiers (weapon/stance/etc.), if present in data
        local users = rec.Users
local qual = eff.Weapon or eff["Weapon"] or eff["Weapon Type"]
        if type(users) ~= "table" then
or eff.Stance or eff["Stance"] or eff["Stance Type"]
                return false
        end


if type(qual) == "string" and qual ~= "" then
        local userLower = mw.ustring.lower(userName)
table.insert(detail, qual)
end


local right = (#detail > 0) and table.concat(detail, ", ") or ""
        local function listHas(list)
table.insert(rows, { label = name, value = right })
                if type(list) ~= "table" then
end
                        return false
end
                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 rows
        return listHas(users.Classes) or listHas(users.Summons) or listHas(users.Monsters) or listHas(users.Events)
end
end


local function formatStatusApplications(list)
----------------------------------------------------------------------
if type(list) ~= "table" or #list == 0 then
-- Slot config
return nil
----------------------------------------------------------------------
end


local parts = {}
local HERO_SLOT_ASSIGNMENT = {
        [1] = "IconName",
        [2] = "Description",
        [3] = "LevelSelector",
        [4] = "PassiveEffects",
}


for _, s in ipairs(list) do
local PLUGINS = {}
if type(s) == "table" then
local scope = s.Scope or "Target"
local name  = s["Status Name"] or s["Status ID"] or "Unknown status"


local seg = scope .. " – " .. name
----------------------------------------------------------------------
local detail = {}
-- Slot scaffolds
----------------------------------------------------------------------


local dur = s.Duration
local function slotBox(slot, extraClasses, innerHtml, opts)
if type(dur) == "table" then
        opts = opts or {}
local t = formatBasePer(dur)
if t then
table.insert(detail, "Duration " .. t)
end
end


local ch = s.Chance
        local box = mw.html.create("div")
if type(ch) == "table" then
        box:addClass("sv-slot")
local t = formatBasePer(ch)
        box:addClass("sv-slot--" .. tostring(slot))
if t then
        box:attr("data-hero-slot", tostring(slot))
table.insert(detail, "Chance " .. t)
end
end


if s["Fixed Duration"] then
        if opts.isFull then
table.insert(detail, "Fixed duration")
                box:addClass("sv-slot--full")
end
        end


if #detail > 0 then
        if extraClasses then
seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
                if type(extraClasses) == "string" then
end
                        box:addClass(extraClasses)
                elseif type(extraClasses) == "table" then
                        for _, c in ipairs(extraClasses) do box:addClass(c) end
                end
        end


table.insert(parts, seg)
        if opts.isEmpty then
end
                box:addClass("sv-slot--empty")
end
        end


return asUl(parts)
        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 formatStatusRemoval(list)
local function normalizeResult(res)
if type(list) ~= "table" or #list == 0 then
        if res == nil then return nil end
return nil
        if type(res) == "string" then
end
                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 parts = {}
        local raw = tostring(inner)


for _, r in ipairs(list) do
        for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do
if type(r) == "table" then
                if mw.ustring.find(raw, pat) then
local names = r["Status Name"]
                        return false
local label
                end
if type(names) == "table" then
        end
label = table.concat(names, ", ")
elseif type(names) == "string" then
label = names
else
label = "Status"
end


local bp = formatBasePer(r)
        local trimmed = mw.text.trim(raw)
local seg = label
        if trimmed == "" or trimmed == "" then
if bp then
                return true
seg = seg .. " " .. bp
        end
end
table.insert(parts, seg)
end
end


return asUl(parts)
        local withoutTags = mw.text.trim(mw.ustring.gsub(trimmed, "<[^>]+>", ""))
        return (withoutTags == "" or withoutTags == "—")
end
end


local function formatEvents(list)
local function renderHeroSlot(slotIndex, rec, ctx)
if type(list) ~= "table" or #list == 0 then
        local pluginName = HERO_SLOT_ASSIGNMENT[slotIndex]
return nil
        if not pluginName then
end
                return nil
        end


local parts = {}
        local res = safeCallPlugin(pluginName, rec, ctx)
for _, ev in ipairs(list) do
        if not res or isEmptySlotContent(res.inner) then
if type(ev) == "table" then
                return nil
local action = ev.Action or "On event"
        end
local name  = ev["Skill Name"] or ev["Skill ID"] or "Unknown skill"
table.insert(parts, string.format("%s → %s", action, name))
end
end


return asUl(parts)
        return {
                inner = res.inner,
                classes = res.classes,
        }
end
end


local function formatModifiers(mods)
local function buildHeroSlotsUI(rec, ctx)
if type(mods) ~= "table" then
        local grid = mw.html.create("div")
return nil
        grid:addClass("sv-slot-grid")
end


local parts = {}
        local slots = {}
        for slot = 1, 4 do
                slots[slot] = renderHeroSlot(slot, rec, ctx)
        end


local function collect(label, sub)
        local hasSlots = false
if type(sub) ~= "table" then
        for _, pair in ipairs({ { 1, 2 }, { 3, 4 } }) do
return
                local left  = slots[pair[1]]
end
                local right = slots[pair[2]]
local flags = {}
for k, v in pairs(sub) do
if v then
table.insert(flags, k)
end
end
table.sort(flags)
if #flags > 0 then
table.insert(parts, string.format("%s: %s", label, table.concat(flags, ", ")))
end
end


collect("Movement", mods["Movement Modifiers"])
                if left or right then
collect("Combat",  mods["Combat Modifiers"])
                        hasSlots = true
collect("Special",  mods["Special Modifiers"])


return asUl(parts)
                        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


----------------------------------------------------------------------
----------------------------------------------------------------------
-- User matching (for auto lists on class pages)
-- Plug-ins
----------------------------------------------------------------------
----------------------------------------------------------------------


local function passiveMatchesUser(rec, userName)
function PLUGINS.IconName(rec)
if type(rec) ~= "table" or not userName or userName == "" then
        local icon  = rec.Icon
return false
        local title = resolveDisplayName(rec)
end
 
        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


local users = rec.Users
                if #reqWeapons > 0 then
if type(users) ~= "table" then
                        local section = reqContent:tag("div")
return false
                        section:addClass("sv-tip-section")
end
                        section:tag("span"):addClass("sv-tip-label"):wikitext("Required Weapons")
                        section:tag("div"):wikitext(table.concat(reqWeapons, ", "))
                end


local userLower = mw.ustring.lower(userName)
                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


local function listHas(list)
        return {
if type(list) ~= "table" then
                inner = tostring(wrap),
return false
                classes = "module-icon-name",
end
        }
for _, v in ipairs(list) do
end
if type(v) == "string" and mw.ustring.lower(v) == userLower then
return true
end
end
return false
end


if listHas(users.Classes) then return true end
function PLUGINS.Description(rec)
if listHas(users.Summons) then return true end
        local desc = trim(rec.Description)
if listHas(users.Monsters) then return true end
        if not desc then
if listHas(users.Events)  then return true end
                return nil
        end


return false
        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)
-- Infobox builder
        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")


local function buildInfobox(rec)
        if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
local root = mw.html.create("table")
                slider:tag("input")
root:addClass("spiritvale-passive-infobox")
                        :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 {
-- Top "hero" rows: title row (icon + name), then description row
                inner = tostring(inner),
-- ==========================================================
                classes = "module-level-selector",
local icon  = rec.Icon
        }
local title = rec.Name or rec["Internal Name"] or "Unknown Passive"
end
local desc  = rec.Description or ""


-- Row 1: centered icon + title (single cell)
function PLUGINS.PassiveEffects(rec, ctx)
local titleRow = root:tag("tr")
        local effects = rec["Passive Effects"]
titleRow:addClass("spiritvale-infobox-main")
        if type(effects) ~= "table" or #effects == 0 then
titleRow:addClass("sv-hero-title-row")
                return nil
        end


local titleCell = titleRow:tag("th")
        local root = mw.html.create("div")
titleCell:attr("colspan", 2)
        root:addClass("sv-passive-effects")
        root:addClass("sv-compact-root")


local titleInner = titleCell:tag("div")
        local tbl = root:tag("div")
titleInner:addClass("spiritvale-infobox-main-left-inner")
        tbl:addClass("sv-pe-table")


if icon and icon ~= "" then
        local added = false
titleInner:wikitext(string.format("[[File:%s|80px|link=]]", icon))
        local maxLevel = ctx.maxLevel or 1
end
        local level = ctx.level or 1


titleInner:tag("div")
        for _, eff in ipairs(effects) do
:addClass("spiritvale-infobox-title")
                if type(eff) == "table" then
:wikitext(title)
                        local t = eff.Type or {}
                        local label = t.Name or t.ID or eff.ID or "Unknown"


-- Row 2: description (single cell)
                        local valueBlock = eff.Value
if desc ~= "" then
                        local display
local descRow = root:tag("tr")
                        if type(valueBlock) == "table" and (valueBlock.Base ~= nil or valueBlock["Per Level"] ~= nil or type(valueBlock["Per Level"]) == "table") then
descRow:addClass("spiritvale-infobox-main")
                                display = displayFromSeries(seriesFromValuePair(valueBlock, maxLevel), level)
descRow:addClass("sv-hero-desc-row")
                        elseif valueBlock ~= nil then
                                display = mw.text.nowiki(tostring(valueBlock))
                        end


local descCell = descRow:tag("td")
                        if not display and type(valueBlock) == "table" then
descCell:attr("colspan", 2)
                                local expr = valueBlock.Expression or valueBlock.expression
                                if expr and trim(tostring(expr)) then
                                        display = mw.text.nowiki(tostring(expr))
                                end
                        end


local descInner = descCell:tag("div")
                        if not display then
descInner:addClass("spiritvale-infobox-main-right-inner")
                                local expr = eff.Expression or eff.expression
                                if expr and trim(tostring(expr)) then
                                        display = mw.text.nowiki(tostring(expr))
                                end
                        end


-- Use <i> to avoid apostrophes breaking ''...'' formatting
                        local qual = eff.Weapon or eff["Weapon"] or eff["Weapon Type"] or eff.Stance or eff["Stance"] or eff["Stance Type"]
local d = descInner:tag("div")
                        if display and qual and trim(tostring(qual)) then
d:addClass("spiritvale-infobox-description")
                                display = tostring(display) .. " (" .. mw.text.nowiki(tostring(qual)) .. ")"
d:tag("i"):wikitext(desc)
                        end
end


------------------------------------------------------------------
                        if display then
-- General
                                added = true
------------------------------------------------------------------
                                local row = tbl:tag("div")
addSectionHeader(root, "General")
                                row:addClass("sv-pe-row")
addRow(root, "Max Level", rec["Max Level"] and tostring(rec["Max Level"]))
                                row:tag("div"):addClass("sv-pe-label"):wikitext(mw.text.nowiki(label))
                                row:tag("div"):addClass("sv-pe-value"):wikitext(display)
                        end
                end
        end


-- Classes intentionally removed (template is used on class pages)
        if not added then
local users = rec.Users or {}
                return nil
addRow(root, "Summons",  listToText(users.Summons))
        end
addRow(root, "Monsters", listToText(users.Monsters))
addRow(root, "Events",  listToText(users.Events))


------------------------------------------------------------------
        return {
-- Requirements
                inner = tostring(root),
------------------------------------------------------------------
                classes = "module-passive-effects",
local req = rec.Requirements or {}
        }
if (req["Required Skills"] and #req["Required Skills"] > 0)
end
or (req["Required Weapons"] and #req["Required Weapons"] > 0)
or (req["Required Stances"] and #req["Required Stances"] > 0) then


addSectionHeader(root, "Requirements")
----------------------------------------------------------------------
-- Infobox builder
----------------------------------------------------------------------


if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
local function buildInfobox(rec, opts)
local skillParts = {}
        opts = opts or {}
for _, rs in ipairs(req["Required Skills"]) do
local sname = rs["Skill Name"] or rs["Skill ID"] or "Unknown"
local level = rs["Required Level"]
if level then
table.insert(skillParts, string.format("%s (Lv.%s)", sname, level))
else
table.insert(skillParts, sname)
end
end
addRow(root, "Required Skills", table.concat(skillParts, ", "))
end


addRow(root, "Required Weapons", listToText(req["Required Weapons"]))
        local maxLevel = tonumber(rec["Max Level"]) or 1
addRow(root, "Required Stances", listToText(req["Required Stances"]))
        if maxLevel < 1 then maxLevel = 1 end
end
        local level = clamp(maxLevel, 1, maxLevel)


------------------------------------------------------------------
        local ctx = {
-- Passive Effects (one row per effect)
                maxLevel = maxLevel,
------------------------------------------------------------------
                level = level,
local peRows = passiveEffectRows(rec["Passive Effects"])
        }
if #peRows > 0 then
addSectionHeader(root, "Passive Effects")
for _, r in ipairs(peRows) do
addRow(root, r.label, r.value)
end
end


------------------------------------------------------------------
        local root = mw.html.create("table")
-- Status Effects
        root:addClass("spiritvale-passive-infobox")
------------------------------------------------------------------
        root:addClass("sv-passive-card")
local statusApps = formatStatusApplications(rec["Status Applications"])
        root:addClass("sv-slot-card")
local statusRem  = formatStatusRemoval(rec["Status Removal"])
        root:attr("data-max-level", tostring(maxLevel))
if statusApps or statusRem then
        root:attr("data-level", tostring(level))
addSectionHeader(root, "Status Effects")
addRow(root, "Applies", statusApps)
addRow(root, "Removes", statusRem)
end


------------------------------------------------------------------
        if opts.inList then
-- Modifiers
                root:addClass("sv-passive-inlist")
------------------------------------------------------------------
        end
local modsText = formatModifiers(rec.Modifiers)
if modsText then
addSectionHeader(root, "Modifiers")
addRow(root, "Flags", modsText)
end


------------------------------------------------------------------
        local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
-- Events
        if internalId then
------------------------------------------------------------------
                root:attr("data-passive-id", internalId)
local eventsText = formatEvents(rec.Events)
        end
if eventsText then
addSectionHeader(root, "Events")
addRow(root, "Triggers", eventsText)
end


------------------------------------------------------------------
        addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx))
-- Notes
------------------------------------------------------------------
if type(rec.Notes) == "table" and #rec.Notes > 0 then
addSectionHeader(root, "Notes")
addRow(root, "Notes", asUl(rec.Notes) or table.concat(rec.Notes, "<br />"))
end


return tostring(root)
        return tostring(root)
end
end


Line 472: Line 767:


function p.listForUser(frame)
function p.listForUser(frame)
local args = getArgs(frame)
        local args = getArgs(frame)
 
-- Prefer explicit param, then unnamed, then fall back to the current page name.
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
        local userName = args.user or args[1]
return "<strong>No user name provided to Passive list.</strong>"
        if not userName or userName == "" then
end
                userName = mw.title.getCurrentTitle().text
        end


local dataset = getPassives()
        if not userName or userName == "" then
local matches = {}
                return "<strong>No user name provided to Passive list.</strong>"
        end


for _, rec in ipairs(dataset.records or {}) do
        local dataset = getPassives()
if passiveMatchesUser(rec, userName) then
        local matches = {}
table.insert(matches, rec)
end
end


if #matches == 0 then
        for _, rec in ipairs(dataset.records or {}) do
return string.format(
                if passiveMatchesUser(rec, userName) then
"<strong>No passives found for:</strong> %s",
                        table.insert(matches, rec)
mw.text.nowiki(userName)
                end
)
        end
end


local root = mw.html.create("div")
        if #matches == 0 then
root:addClass("spiritvale-passive-list")
                return string.format(
                        "<strong>No passives found for:</strong> %s",
                        mw.text.nowiki(userName)
                )
        end


for _, rec in ipairs(matches) do
        local parts = {}
root:wikitext(buildInfobox(rec))
        for _, rec in ipairs(matches) do
end
                local title = resolveDisplayName(rec)
                table.insert(parts, string.format("=== %s ===\n%s", title, buildInfobox(rec, { inList = true })))
        end


return tostring(root)
        return table.concat(parts, "\n")
end
end


Line 515: Line 808:


function p.infobox(frame)
function p.infobox(frame)
local args = getArgs(frame)
        local args = getArgs(frame)


local raw1 = args[1]
        local raw1 = args[1]
local name = args.name or raw1
        local name = args.name or raw1
local id  = args.id
        local id  = args.id


local rec
        local rec


-- 1) Prefer display Name
        if name and name ~= "" then
if name and name ~= "" then
                rec = findPassiveByName(name)
rec = findPassiveByName(name)
        end
end


-- 2) Fallback: internal ID
        if not rec and id and id ~= "" then
if not rec and id and id ~= "" then
                rec = getPassiveById(id)
rec = getPassiveById(id)
        end
end


-- 3) If still nothing, decide if this is "list mode" or truly unknown.
        if not rec then
if not rec then
                local pageTitle = mw.title.getCurrentTitle()
local pageTitle = mw.title.getCurrentTitle()
                local pageName  = pageTitle and pageTitle.text or ""
local pageName  = pageTitle and pageTitle.text or ""


local noExplicitArgs =
                local noExplicitArgs =
(not raw1 or raw1 == "") and
                        (not raw1 or raw1 == "") and
(not args.name or args.name == "") and
                        (not args.name or args.name == "") and
(not id or id == "")
                        (not id or id == "")


if noExplicitArgs then
                if noExplicitArgs then
return p.listForUser(frame)
                        return p.listForUser(frame)
end
                end


if name and name ~= "" and name == pageName and (not id or id == "") then
                if name and name ~= "" and name == pageName and (not id or id == "") then
return p.listForUser(frame)
                        return p.listForUser(frame)
end
                end


local label = name or id or "?"
                local label = name or id or "?"
return string.format(
                return string.format(
"<strong>Unknown passive:</strong> %s[[Category:Pages with unknown passive|%s]]",
                        "<strong>Unknown passive:</strong> %s[[Category:Pages with unknown passive|%s]]",
mw.text.nowiki(label),
                        mw.text.nowiki(label),
label
                        label
)
                )
end
        end


return buildInfobox(rec)
        return buildInfobox(rec)
end
end


return p
return p