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
 
(10 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
    end
 
    return frame.args
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 listToText(list, sep)
local function toNum(v)
    if type(list) ~= "table" or #list == 0 then
        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
         return nil
    end
    return table.concat(list, sep or ", ")
end
end


local function addRow(tbl, label, value)
local function clamp(n, lo, hi)
    if value == nil or value == "" then
        if type(n) ~= "number" then
        return
                return lo
    end
        end
    local row = tbl:tag("tr")
        if n < lo then return lo end
    row:tag("th"):wikitext(label):done()
        if n > hi then return hi end
    row:tag("td"):wikitext(value):done()
        return n
end
end


local function addSectionHeader(tbl, label)
local function fmtNum(n)
    local row = tbl:tag("tr")
        if type(n) ~= "number" then
    local cell = row:tag("th")
                return (n ~= nil) and tostring(n) or nil
    cell:attr("colspan", 2)
        end
    cell:addClass("spiritvale-infobox-section-header")
 
    cell:wikitext(label)
        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


-- Lookup by Internal Name
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)
local function getPassiveById(id)
    if not id or id == "" then
        id = trim(id)
        return nil
        if not id then return nil end
    end
        local dataset = getPassives()
    local dataset = getPassives()
        local byId = dataset.byId or {}
    local byId = dataset.byId or {}
        return byId[id]
    return byId[id]
end
end


-- Lookup by display Name (for editors)
local function findPassiveByName(name)
local function findPassiveByName(name)
    if not name or name == "" then
        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
         return nil
    end
end
    local dataset = getPassives()
 
    for _, rec in ipairs(dataset.records or {}) do
local function resolveDisplayName(rec)
         if rec["Name"] == name then
         if type(rec) ~= "table" then
            return rec
                return tostring(rec or "")
         end
         end
    end
        return rec["External Name"] or rec.Name or rec["Display Name"] or rec["Internal Name"] or rec.ID or "Unknown Passive"
    return nil
end
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Formatting helpers
-- User matching (for auto lists on class pages)
----------------------------------------------------------------------
----------------------------------------------------------------------


local function formatBasePer(block)
local function passiveMatchesUser(rec, userName)
    if type(block) ~= "table" then
        if type(rec) ~= "table" or not userName or userName == "" then
        return nil
                return false
    end
        end
    local parts = {}
 
    if block.Base ~= nil then
        local users = rec.Users
         table.insert(parts, string.format("Base %s", tostring(block.Base)))
        if type(users) ~= "table" then
    end
                return false
    if block["Per Level"] ~= nil then
         end
        table.insert(parts, string.format("%s / Lv", tostring(block["Per Level"])))
 
    end
        local userLower = mw.ustring.lower(userName)
    if #parts == 0 then
 
        return nil
        local function listHas(list)
    end
                if type(list) ~= "table" then
    return table.concat(parts, ", ")
                        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


local function formatPassiveEffects(list)
----------------------------------------------------------------------
    if type(list) ~= "table" or #list == 0 then
-- Slot config
         return nil
----------------------------------------------------------------------
    end
 
local HERO_SLOT_ASSIGNMENT = {
        [1] = "IconName",
        [2] = "Description",
        [3] = "LevelSelector",
         [4] = "PassiveEffects",
}


    local parts = {}
local PLUGINS = {}


    for _, eff in ipairs(list) do
----------------------------------------------------------------------
        if type(eff) == "table" then
-- Slot scaffolds
            local t = eff.Type or {}
----------------------------------------------------------------------
            local name = t.Name or eff.ID or "Unknown"
            local value = eff.Value or {}


            local detail = {}
local function slotBox(slot, extraClasses, innerHtml, opts)
        opts = opts or {}


            if value.Base ~= nil then
        local box = mw.html.create("div")
                table.insert(detail, string.format("Base %s", tostring(value.Base)))
        box:addClass("sv-slot")
            end
        box:addClass("sv-slot--" .. tostring(slot))
            if value["Per Level"] ~= nil then
        box:attr("data-hero-slot", tostring(slot))
                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


            local seg = name
        if opts.isFull then
            if #detail > 0 then
                 box:addClass("sv-slot--full")
                 seg = seg -- name
        end
                    .. " – " .. table.concat(detail, ", ")
            end


            table.insert(parts, seg)
        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
         end
    end


    if #parts == 0 then
        if opts.isEmpty then
         return nil
                box:addClass("sv-slot--empty")
    end
        end
 
        local body = box:tag("div"):addClass("sv-slot__body")
        if innerHtml and innerHtml ~= "" then
                body:wikitext(innerHtml)
         end


    return table.concat(parts, "<br />")
        return tostring(box)
end
end


local function formatStatusApplications(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 parts = {}
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


    for _, s in ipairs(list) do
local function isEmptySlotContent(inner)
         if type(s) == "table" then
         if inner == nil then return true end
            local scope = s.Scope or "Target"
            local name  = s["Status Name"] or s["Status ID"] or "Unknown status"


            local seg = scope .. " – " .. name
        local raw = tostring(inner)
            local detail = {}


            local dur = s.Duration
        for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do
            if type(dur) == "table" then
                 if mw.ustring.find(raw, pat) then
                local t = formatBasePer(dur)
                        return false
                 if t then
                    table.insert(detail, "Duration " .. t)
                 end
                 end
            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


            local ch = s.Chance
        return {
            if type(ch) == "table" then
                inner = res.inner,
                 local t = formatBasePer(ch)
                classes = res.classes,
                 if t then
        }
                    table.insert(detail, "Chance " .. t)
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
            end
        end


            if s["Fixed Duration"] then
        if not hasSlots then
                 table.insert(detail, "Fixed duration")
                 return ""
            end
        end


            if #detail > 0 then
        return tostring(grid)
                seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
end
            end


            table.insert(parts, seg)
local function addHeroSlotsRow(tbl, slotsUI)
        if not slotsUI or slotsUI == "" then
                return
         end
         end
    end


    if #parts == 0 then
        local row = tbl:tag("tr")
         return nil
         row:addClass("sv-slot-row")
    end


    return table.concat(parts, "<br />")
        local cell = row:tag("td")
        cell:attr("colspan", 2)
        cell:addClass("sv-slot-cell")
        cell:wikitext(slotsUI)
end
end


local function formatStatusRemoval(list)
----------------------------------------------------------------------
    if type(list) ~= "table" or #list == 0 then
-- Plug-ins
         return nil
----------------------------------------------------------------------
    end
 
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 parts = {}
        local reqWeapons = {}
    for _, r in ipairs(list) do
        for _, w in ipairs(reqWeaponsRaw) do
        if type(r) == "table" then
                local wn = trim(w)
            local names = r["Status Name"]
                 if wn then table.insert(reqWeapons, mw.text.nowiki(wn)) end
            local label
        end
            if type(names) == "table" then
                 label = table.concat(names, ", ")
            elseif type(names) == "string" then
                label = names
            else
                label = "Status"
            end


            local bp = formatBasePer(r)
        local reqStances = {}
            local seg = label
        for _, s in ipairs(reqStancesRaw) do
            if bp then
                local sn = trim(s)
                seg = seg .. " – " .. bp
                if sn then table.insert(reqStances, mw.text.nowiki(sn)) end
            end
            table.insert(parts, seg)
         end
         end
    end


    if #parts == 0 then
        local hasNotes = (#notesList > 0)
         return nil
         local hasReq = (#reqSkills > 0) or (#reqWeapons > 0) or (#reqStances > 0)
    end


    return table.concat(parts, "<br />")
        local wrap = mw.html.create("div")
end
        wrap:addClass("sv-herobar-1-wrap")
        wrap:addClass("sv-tip-scope")


local function formatEvents(list)
        local iconBox = wrap:tag("div")
    if type(list) ~= "table" or #list == 0 then
        iconBox:addClass("sv-herobar-icon")
        return nil
    end


    local parts = {}
         if icon and icon ~= "" then
    for _, ev in ipairs(list) do
                iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon))
         if type(ev) == "table" then
            local action = ev.Action or "On event"
            local name  = ev["Skill Name"] or ev["Skill ID"] or "Unknown skill"
            local seg    = string.format("%s → %s", action, name)
            table.insert(parts, seg)
         end
         end
    end


    if #parts == 0 then
        local textBox = wrap:tag("div")
         return nil
         textBox:addClass("sv-herobar-text")
    end


    return table.concat(parts, "<br />")
        local titleRow = textBox:tag("div")
end
        titleRow:addClass("sv-herobar-title-row")


local function formatModifiers(mods)
        local titleBox = titleRow:tag("div")
    if type(mods) ~= "table" then
        titleBox:addClass("spiritvale-infobox-title")
         return nil
         titleBox:wikitext(title)
    end


    local parts = {}
        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


    local function collect(label, sub)
        if hasReq then
        if type(sub) ~= "table" then
                local pillRow = wrap:tag("div")
            return
                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
         end
         local flags = {}
 
        for k, v in pairs(sub) do
         if hasNotes then
            if v then
                local notesContent = wrap:tag("div")
                 table.insert(flags, k)
                notesContent:addClass("sv-tip-content")
            end
                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
         end
         table.sort(flags)
 
        if #flags > 0 then
         if hasReq then
            table.insert(parts, string.format("%s: %s", label, table.concat(flags, ", ")))
                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
         end
    end


    collect("Movement", mods["Movement Modifiers"])
        return {
    collect("Combat",   mods["Combat Modifiers"])
                inner = tostring(wrap),
    collect("Special",  mods["Special Modifiers"])
                classes = "module-icon-name",
        }
end


    if #parts == 0 then
function PLUGINS.Description(rec)
        return nil
        local desc = trim(rec.Description)
    end
        if not desc then
                return nil
        end
 
        local body = mw.html.create("div")
        body:addClass("sv-description")
        body:wikitext(string.format("''%s''", desc))


    return table.concat(parts, "<br />")
        return {
                inner = tostring(body),
                classes = "module-description",
        }
end
end


----------------------------------------------------------------------
function PLUGINS.LevelSelector(rec, ctx)
-- User matching (for auto lists on class pages)
        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 function passiveMatchesUser(rec, userName)
        local slider = inner:tag("div"):addClass("sv-level-slider")
    if type(rec) ~= "table" or not userName or userName == "" then
        return false
    end


    local users = rec.Users
        if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
    if type(users) ~= "table" then
                slider:tag("input")
         return false
                        :attr("type", "range")
    end
                        :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


    local userLower = mw.ustring.lower(userName)
        return {
                inner = tostring(inner),
                classes = "module-level-selector",
        }
end


    local function listHas(list)
function PLUGINS.PassiveEffects(rec, ctx)
         if type(list) ~= "table" then
        local effects = rec["Passive Effects"]
            return false
         if type(effects) ~= "table" or #effects == 0 then
                return nil
         end
         end
         for _, v in ipairs(list) do
 
            if type(v) == "string" and mw.ustring.lower(v) == userLower then
        local root = mw.html.create("div")
                return true
        root:addClass("sv-passive-effects")
            end
        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
         end
        return false
    end


    if listHas(users.Classes)  then return true end
        if not added then
    if listHas(users.Summons)  then return true end
                return nil
    if listHas(users.Monsters) then return true end
        end
    if listHas(users.Events)  then return true end


    return false
        return {
                inner = tostring(root),
                classes = "module-passive-effects",
        }
end
end


Line 329: Line 729:
----------------------------------------------------------------------
----------------------------------------------------------------------


local function buildInfobox(rec)
local function buildInfobox(rec, opts)
    local root = mw.html.create("table")
        opts = opts or {}
    root:addClass("wikitable spiritvale-passive-infobox")
 
        local maxLevel = tonumber(rec["Max Level"]) or 1
        if maxLevel < 1 then maxLevel = 1 end
        local level = clamp(maxLevel, 1, maxLevel)


    -- ==========================================================
        local ctx = {
    -- Top "hero" row: icon + name (left), description (right)
                maxLevel = maxLevel,
    -- ==========================================================
                level = level,
    local icon  = rec.Icon
        }
    local title = rec.Name or rec["Internal Name"] or "Unknown Passive"
    local desc  = rec.Description or ""


    local headerRow = root:tag("tr")
        local root = mw.html.create("table")
    headerRow:addClass("spiritvale-infobox-main")
        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))


    -- Left cell: icon + name
        if opts.inList then
    local leftCell = headerRow:tag("th")
                root:addClass("sv-passive-inlist")
    leftCell:addClass("spiritvale-infobox-main-left")
        end


    local leftInner = leftCell:tag("div")
        local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
    leftInner:addClass("spiritvale-infobox-main-left-inner")
        if internalId then
                root:attr("data-passive-id", internalId)
        end


    if icon and icon ~= "" then
         addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx))
         leftInner:wikitext(string.format("[[File:%s|80px|link=]]", icon))
    end


    leftInner:tag("div")
        return tostring(root)
        :addClass("spiritvale-infobox-title")
end
         :wikitext(title)
 
----------------------------------------------------------------------
-- 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


    -- Right cell: italic description
        local dataset = getPassives()
    local rightCell = headerRow:tag("td")
        local matches = {}
    rightCell:addClass("spiritvale-infobox-main-right")


    local rightInner = rightCell:tag("div")
        for _, rec in ipairs(dataset.records or {}) do
    rightInner:addClass("spiritvale-infobox-main-right-inner")
                if passiveMatchesUser(rec, userName) then
                        table.insert(matches, rec)
                end
        end


    if desc ~= "" then
        if #matches == 0 then
        rightInner:tag("div")
                return string.format(
            :addClass("spiritvale-infobox-description")
                        "<strong>No passives found for:</strong> %s",
            :wikitext(string.format("''%s''", desc))
                        mw.text.nowiki(userName)
    end
                )
        end


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


    -- Description is now in the hero row; don't repeat it here.
        return table.concat(parts, "\n")
    -- addRow(root, "Description", rec.Description)
end


    addRow(root, "Max level", rec["Max Level"] and tostring(rec["Max Level"]))
----------------------------------------------------------------------
-- Public: single-passive or auto-list dispatcher
----------------------------------------------------------------------


    local users = rec.Users or {}
function p.infobox(frame)
    addRow(root, "Classes",  listToText(users.Classes))
        local args = getArgs(frame)
    addRow(root, "Summons",  listToText(users.Summons))
    addRow(root, "Monsters", listToText(users.Monsters))
    addRow(root, "Events",  listToText(users.Events))


    ------------------------------------------------------------------
        local raw1 = args[1]
    -- Requirements
         local name = args.name or raw1
    ------------------------------------------------------------------
         local id  = args.id
    local req = rec.Requirements or {}
    if (req["Required Skills"] and #req["Required Skills"] > 0)
         or (req["Required Weapons"] and #req["Required Weapons"] > 0)
         or (req["Required Stances"] and #req["Required Stances"] > 0) then


         addSectionHeader(root, "Requirements")
         local rec


         if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
         if name and name ~= "" then
            local skillParts = {}
                 rec = findPassiveByName(name)
            for _, rs in ipairs(req["Required Skills"]) do
                local name = 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)", name, level))
                else
                    table.insert(skillParts, name)
                end
            end
            addRow(root, "Required skills", table.concat(skillParts, ", "))
         end
         end


         addRow(root, "Required weapons", listToText(req["Required Weapons"]))
         if not rec and id and id ~= "" then
         addRow(root, "Required stances", listToText(req["Required Stances"]))
                rec = getPassiveById(id)
    end
         end


    ------------------------------------------------------------------
        if not rec then
    -- Passive effects
                local pageTitle = mw.title.getCurrentTitle()
    ------------------------------------------------------------------
                local pageName  = pageTitle and pageTitle.text or ""
    local peText = formatPassiveEffects(rec["Passive Effects"])
    if peText then
        addSectionHeader(root, "Passive effects")
        addRow(root, "Effects", peText)
    end


    ------------------------------------------------------------------
                local noExplicitArgs =
    -- Modifiers
                        (not raw1 or raw1 == "") and
    ------------------------------------------------------------------
                        (not args.name or args.name == "") and
    local modsText = formatModifiers(rec.Modifiers)
                        (not id or id == "")
    if modsText then
        addSectionHeader(root, "Modifiers")
        addRow(root, "Flags", modsText)
    end


    ------------------------------------------------------------------
                if noExplicitArgs then
    -- Status
                        return p.listForUser(frame)
    ------------------------------------------------------------------
                end
    local statusApps = formatStatusApplications(rec["Status Applications"])
    local statusRem  = formatStatusRemoval(rec["Status Removal"])
    if statusApps or statusRem then
        addSectionHeader(root, "Status effects")
        addRow(root, "Applies", statusApps)
        addRow(root, "Removes", statusRem)
    end


    ------------------------------------------------------------------
                if name and name ~= "" and name == pageName and (not id or id == "") then
    -- Events
                        return p.listForUser(frame)
    ------------------------------------------------------------------
                end
    local eventsText = formatEvents(rec.Events)
    if eventsText then
        addSectionHeader(root, "Events")
        addRow(root, "Triggers", eventsText)
    end


    ------------------------------------------------------------------
                local label = name or id or "?"
    -- Notes
                return string.format(
    ------------------------------------------------------------------
                        "<strong>Unknown passive:</strong> %s[[Category:Pages with unknown passive|%s]]",
    if type(rec.Notes) == "table" and #rec.Notes > 0 then
                        mw.text.nowiki(label),
        addSectionHeader(root, "Notes")
                        label
        addRow(root, "Notes", table.concat(rec.Notes, "<br />"))
                )
    end
        end


    return tostring(root)
        return buildInfobox(rec)
end
end
return p