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:GameSkills: Difference between revisions

From SpiritVale Wiki
No edit summary
No edit summary
Line 1: Line 1:
-- Module:GameSkills
-- Module:GameSkills
--
--
-- Renders skill data (from Data:skills.json) into an infobox/table.
-- Renders active skill data (from Data:skills.json) into an infobox-style table
-- Data is loaded via Module:GameData.
-- and can also list all skills for a given user/class.
--
--
-- Supported usage patterns (via Template:Skill):
-- Usage (single skill):
--  {{Skill|Bash}}                 -- uses display Name (recommended)
--  {{Skill|Heal}}
--  {{Skill|name=Bash}}             -- explicit name
--  {{Skill|name=Heal}}
--  {{Skill|id=Bash_InternalId}}   -- internal ID (power use)
--  {{Skill|id=Heal_InternalId}}
--
-- Usage (auto-list on class page, e.g. "Acolyte"):
--  {{Skill}}                  -> lists all Acolyte skills (page name)
--  {{Skill|Acolyte}}          -> same, if no skill literally called "Acolyte"


local GameData = require("Module:GameData")
local GameData = require("Module:GameData")
Line 27: Line 31:


local function getArgs(frame)
local function getArgs(frame)
    -- Prefer parent template args if present (usual #invoke pattern)
     local parent = frame:getParent()
     local parent = frame:getParent()
     if parent then
     if parent then
Line 35: Line 38:
end
end


local function listToText(list)
local function listToText(list, sep)
     if type(list) ~= "table" or #list == 0 then
     if type(list) ~= "table" or #list == 0 then
         return nil
         return nil
     end
     end
     return table.concat(list, ", ")
     return table.concat(list, sep or ", ")
end
end


Line 51: Line 54:
end
end


local function formatBasePer(block)
local function addSectionHeader(tbl, label)
     if type(block) ~= "table" then
     local row = tbl:tag("tr")
    local cell = row:tag("th")
    cell:attr("colspan", 2)
    cell:addClass("spiritvale-infobox-section-header")
    cell:wikitext(label)
end
 
-- Lookup by Internal Name
local function getSkillById(id)
    if not id or id == "" then
         return nil
         return nil
     end
     end
     local base = block.Base
     local dataset = getSkills()
     local per  = block["Per Level"]
     local byId = dataset.byId or {}
    return byId[id]
end


    if base and per then
-- Lookup by display Name (for editors)
        return string.format("%.2f (%.2f / Lv)", base, per)
local function findSkillByName(name)
    elseif base then
     if not name or name == "" then
        return string.format("%.2f", base)
     elseif per then
        return string.format("%.2f / Lv", per)
    else
         return nil
         return nil
     end
     end
    local dataset = getSkills()
    for _, rec in ipairs(dataset.records or {}) do
        if rec["Name"] == name then
            return rec
        end
    end
    return nil
end
end


local function formatManaCost(block)
----------------------------------------------------------------------
-- Formatting helpers
----------------------------------------------------------------------
 
local function formatBasePer(block)
     if type(block) ~= "table" then
     if type(block) ~= "table" then
         return nil
         return nil
     end
     end
    local parts = {}
    if block.Base ~= nil then
        table.insert(parts, string.format("Base %s", tostring(block.Base)))
    end
    if block["Per Level"] ~= nil then
        table.insert(parts, string.format("%s / Lv", tostring(block["Per Level"])))
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, ", ")
end


     local base = block.Base
local function formatMainDamage(list)
    local per  = block["Per Level"]
    if type(list) ~= "table" or #list == 0 then
        return nil
    end
     local parts = {}
    for _, d in ipairs(list) do
        if type(d) == "table" then
            local kind = d.Type or "Damage"
            local base = d["Base %"]
            local per  = d["Per Level %"]
            local seg  = kind
            local detail = {}
            if base ~= nil then
                table.insert(detail, string.format("Base %s%%", tostring(base)))
            end
            if per ~= nil then
                table.insert(detail, string.format("%s%% / Lv", tostring(per)))
            end
            if d["ATK-Based"] then
                table.insert(detail, "ATK-based")
            end
            if d["MATK-Based"] then
                table.insert(detail, "MATK-based")
            end
            if #detail > 0 then
                seg = seg .. " – " .. table.concat(detail, ", ")
            end
            table.insert(parts, seg)
        end
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end


     if base and per then
local function formatReflectDamage(list)
         return string.format("%.0f (%.0f / Lv)", base, per)
     if type(list) ~= "table" or #list == 0 then
    elseif base then
         return nil
        return string.format("%.0f", base)
    end
    elseif per then
    local parts = {}
        return string.format("%.0f / Lv", per)
    for _, d in ipairs(list) do
     else
        if type(d) == "table" then
            local base = d["Base %"]
            local per = d["Per Level %"]
            local seg  = "Reflect"
            local detail = {}
            if base ~= nil then
                table.insert(detail, string.format("Base %s%%", tostring(base)))
            end
            if per ~= nil then
                table.insert(detail, string.format("%s%% / Lv", tostring(per)))
            end
            if #detail > 0 then
                seg = seg .. " – " .. table.concat(detail, ", ")
            end
            table.insert(parts, seg)
        end
    end
     if #parts == 0 then
         return nil
         return nil
     end
     end
    return table.concat(parts, "<br />")
end
end


local function formatMainDamage(damageTable)
local function formatScaling(list)
     if type(damageTable) ~= "table" or #damageTable == 0 then
     if type(list) ~= "table" or #list == 0 then
        return nil
    end
    local parts = {}
    for _, s in ipairs(list) do
        if type(s) == "table" then
            local name = s["Scaling Name"] or s["Scaling ID"] or "Unknown"
            local pct  = s.Percent
            local seg  = name
            local detail = {}
            if pct ~= nil then
                table.insert(detail, string.format("%s%%", tostring(pct)))
            end
            if s["ATK-Based"] then
                table.insert(detail, "ATK-based")
            end
            if s["MATK-Based"] then
                table.insert(detail, "MATK-based")
            end
            if #detail > 0 then
                seg = seg .. " – " .. table.concat(detail, ", ")
            end
            table.insert(parts, seg)
        end
    end
    if #parts == 0 then
         return nil
         return nil
     end
     end
    return table.concat(parts, "<br />")
end


local function formatArea(area)
    if type(area) ~= "table" then
        return nil
    end
     local parts = {}
     local parts = {}
     for _, entry in ipairs(damageTable) do
     local size = area["Area Size"]
        local base = entry["Base %"]
    if size and size ~= "" then
        local per = entry["Per Level %"]
        table.insert(parts, "Size: " .. tostring(size))
         local txt
    end
    local dist = area["Area Distance"]
    local eff = area["Effective Distance"]
    local distText = formatBasePer(dist)
    if distText then
        table.insert(parts, "Distance: " .. distText)
    end
    if eff ~= nil then
        table.insert(parts, string.format("Effective: %s", tostring(eff)))
    end
    if #parts == 0 then
         return nil
    end
    return table.concat(parts, "<br />")
end


        if base and per then
local function formatTimingBlock(bt)
            txt = string.format("Base %.2f, +%.2f / Lv", base, per)
    if type(bt) ~= "table" then
        elseif base then
         return nil
            txt = string.format("Base %.2f", base)
    end
        elseif per then
    local parts = {}
            txt = string.format("+%.2f / Lv", per)
         end


    local function add(name, key)
        local block = bt[key]
        local txt = formatBasePer(block)
         if txt then
         if txt then
             table.insert(parts, txt)
             table.insert(parts, name .. ": " .. txt)
         end
         end
    end
    add("Cast Time", "Cast Time")
    add("Cooldown", "Cooldown")
    add("Duration", "Duration")
    if bt["Effect Cast Time"] ~= nil then
        table.insert(parts, "Effect Cast Time: " .. tostring(bt["Effect Cast Time"]))
    end
    if bt["Damage Delay"] ~= nil then
        table.insert(parts, "Damage Delay: " .. tostring(bt["Damage Delay"]))
    end
    if bt["Effect Remove Delay"] ~= nil then
        table.insert(parts, "Effect Remove Delay: " .. tostring(bt["Effect Remove Delay"]))
     end
     end


Line 115: Line 259:
         return nil
         return nil
     end
     end
     return table.concat(parts, "; ")
     return table.concat(parts, "<br />")
end
end


local function formatScaling(scalingList)
local function formatResourceCost(rc)
     if type(scalingList) ~= "table" or #scalingList == 0 then
     if type(rc) ~= "table" then
         return nil
         return nil
    end
    local parts = {}
    local mana = rc["Mana Cost"]
    local hp  = rc["Health Cost"]
    local manaTxt = formatBasePer(mana)
    if manaTxt then
        table.insert(parts, "MP: " .. manaTxt)
    end
    local hpTxt = formatBasePer(hp)
    if hpTxt then
        table.insert(parts, "HP: " .. hpTxt)
     end
     end


    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end
local function formatCombo(combo)
    if type(combo) ~= "table" then
        return nil
    end
    local parts = {}
    if combo.Type then
        table.insert(parts, "Type: " .. tostring(combo.Type))
    end
    if combo.Duration ~= nil then
        table.insert(parts, "Duration: " .. tostring(combo.Duration))
    end
    if combo.Percent ~= nil then
        table.insert(parts, string.format("Bonus: %s%%", tostring(combo.Percent * 100)))
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, ", ")
end
local function formatMechanicEffects(effects)
    if type(effects) ~= "table" then
        return nil
    end
    local parts = {}
    for name, block in pairs(effects) do
        if type(block) == "table" then
            local bp = formatBasePer(block)
            local seg = name
            if bp then
                seg = seg .. " – " .. bp
            end
            table.insert(parts, seg)
        end
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end
local function formatModifiers(mods)
    if type(mods) ~= "table" then
        return nil
    end
     local parts = {}
     local parts = {}
     for _, s in ipairs(scalingList) do
 
         local name = s["Scaling Name"] or s["Scaling ID"] or "Unknown"
     local function collect(label, sub)
         local pct  = s.Percent
         if type(sub) ~= "table" then
         if pct then
            return
             table.insert(parts, string.format("%s: %.2f", name, pct))
        end
        else
         local flags = {}
            table.insert(parts, name)
        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
     end
     end
    collect("Movement", mods["Movement Modifiers"])
    collect("Combat",  mods["Combat Modifiers"])
    collect("Special",  mods["Special Modifiers"])


     if #parts == 0 then
     if #parts == 0 then
         return nil
         return nil
     end
     end
     return table.concat(parts, ", ")
     return table.concat(parts, "<br />")
end
end


Line 144: Line 363:
         return nil
         return nil
     end
     end
     local parts = {}
     local parts = {}
     for _, s in ipairs(list) do
     for _, s in ipairs(list) do
         local scope = s.Scope or "Target"
         if type(s) == "table" then
        local name   = s["Status Name"] or s["Status ID"] or "Unknown status"
            local scope = s.Scope or "Target"
        local dur    = s.Duration and s.Duration.Base
            local name = s["Status Name"] or s["Status ID"] or "Unknown status"
        local chance = s.Chance and s.Chance.Base


        local seg = name
            local seg = scope .. " – " .. name
        if scope and scope ~= "" then
            local detail = {}
             seg = scope .. ": " .. seg
 
        end
            local dur = s.Duration
            if type(dur) == "table" then
                local t = formatBasePer(dur)
                if t then
                    table.insert(detail, "Duration " .. t)
                end
            end
 
            local ch = s.Chance
            if type(ch) == "table" then
                local t = formatBasePer(ch)
                if t then
                    table.insert(detail, "Chance " .. t)
                end
            end
 
             if s["Fixed Duration"] then
                table.insert(detail, "Fixed duration")
            end
 
            if #detail > 0 then
                seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
            end


        local detailParts = {}
             table.insert(parts, seg)
        if dur then
             table.insert(detailParts, string.format("Dur %.2f", dur))
        end
        if chance then
            table.insert(detailParts, string.format("Chance %.2f", chance))
         end
         end
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end


        if #detailParts > 0 then
local function formatStatusRemoval(list)
             seg = seg .. " (" .. table.concat(detailParts, ", ") .. ")"
    if type(list) ~= "table" or #list == 0 then
        return nil
    end
    local parts = {}
    for _, r in ipairs(list) do
        if type(r) == "table" then
             local names = r["Status Name"]
            local label
            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 seg = label
            if bp then
                seg = seg .. " – " .. bp
            end
            table.insert(parts, seg)
         end
         end
        table.insert(parts, seg)
     end
     end
 
    if #parts == 0 then
     return table.concat(parts, "; ")
        return nil
    end
     return table.concat(parts, "<br />")
end
end


-- Lookup by internal ID (for tools / power use)
local function formatEvents(list)
local function getSkillById(id)
    if type(list) ~= "table" or #list == 0 then
    if not id or id == "" then
        return nil
    end
    local parts = {}
    for _, ev in ipairs(list) do
        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
    if #parts == 0 then
         return nil
         return nil
     end
     end
     local dataset = getSkills()
     return table.concat(parts, "<br />")
    local byId = dataset.byId or {}
    return byId[id]
end
end


-- Lookup by display Name (for editors)
----------------------------------------------------------------------
local function findSkillByName(name)
-- User matching (for auto lists on class pages)
     if not name or name == "" then
----------------------------------------------------------------------
         return nil
 
local function skillMatchesUser(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
     end
     local dataset = getSkills()
 
     for _, rec in ipairs(dataset.records or {}) do
     local userLower = mw.ustring.lower(userName)
        if rec["Name"] == name then
 
             return rec
     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
         end
        return false
     end
     end
     return nil
 
     if listHas(users.Classes)  then return true end
    if listHas(users.Summons)  then return true end
    if listHas(users.Monsters) then return true end
    if listHas(users.Events)  then return true end
 
    return false
end
end


Line 222: Line 513:
     headerCell:wikitext(titleText)
     headerCell:wikitext(titleText)


     -- Basic info
     ------------------------------------------------------------------
    -- General
    ------------------------------------------------------------------
    addSectionHeader(root, "General")
     addRow(root, "Description", rec.Description)
     addRow(root, "Description", rec.Description)
     addRow(root, "Max level", rec["Max Level"] and tostring(rec["Max Level"]))
     addRow(root, "Max level", rec["Max Level"] and tostring(rec["Max Level"]))


    -- Users
     local users = rec.Users or {}
     local users = rec.Users or {}
     addRow(root, "Classes",  listToText(users.Classes))
     addRow(root, "Classes",  listToText(users.Classes))
Line 233: Line 526:
     addRow(root, "Events",  listToText(users.Events))
     addRow(root, "Events",  listToText(users.Events))


    ------------------------------------------------------------------
     -- Requirements
     -- Requirements
    ------------------------------------------------------------------
     local req = rec.Requirements or {}
     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")


    if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
        if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
        local skillParts = {}
            local skillParts = {}
        for _, rs in ipairs(req["Required Skills"]) do
            for _, rs in ipairs(req["Required Skills"]) do
            local name  = rs["Skill Name"] or rs["Skill ID"] or "Unknown"
                local name  = rs["Skill Name"] or rs["Skill ID"] or "Unknown"
            local level = rs["Required Level"]
                local level = rs["Required Level"]
            if level then
                if level then
                table.insert(skillParts, string.format("%s (Lv.%s)", name, level))
                    table.insert(skillParts, string.format("%s (Lv.%s)", name, level))
            else
                else
                table.insert(skillParts, name)
                    table.insert(skillParts, name)
                end
             end
             end
            addRow(root, "Required skills", table.concat(skillParts, ", "))
         end
         end
         addRow(root, "Required skills", table.concat(skillParts, ", "))
 
         addRow(root, "Required weapons", listToText(req["Required Weapons"]))
        addRow(root, "Required stances", listToText(req["Required Stances"]))
     end
     end


     addRow(root, "Required weapons", listToText(req["Required Weapons"]))
     ------------------------------------------------------------------
    addRow(root, "Required stances", listToText(req["Required Stances"]))
    -- Type
    ------------------------------------------------------------------
    local typeBlock = rec.Type or {}
    if next(typeBlock) ~= nil then
        addSectionHeader(root, "Type")
 
        local dt = typeBlock["Damage Type"]
        if type(dt) == "table" and dt.Name then
            addRow(root, "Damage type", dt.Name)
        end
 
        local et = typeBlock["Element Type"]
        if type(et) == "table" and et.Name then
            addRow(root, "Element", et.Name)
        end


    -- Type
        local tt = typeBlock["Target Type"]
    local t = rec.Type or {}
        if type(tt) == "table" and tt.Name then
    local damageType = t["Damage Type"] and t["Damage Type"].Name
            addRow(root, "Target", tt.Name)
    local element    = t["Element Type"] and t["Element Type"].Name
        end
    local target    = t["Target Type"]  and t["Target Type"].Name
    local castType  = t["Cast Type"]    and t["Cast Type"].Name


    addRow(root, "Damage type", damageType)
        local ct = typeBlock["Cast Type"]
    addRow(root, "Element",    element)
        if type(ct) == "table" and ct.Name then
    addRow(root, "Targeting",  target)
            addRow(root, "Cast type", ct.Name)
    addRow(root, "Cast type",   castType)
        end
    end


    ------------------------------------------------------------------
     -- Mechanics
     -- Mechanics
     local mech         = rec.Mechanics or {}
    ------------------------------------------------------------------
     local basicTimings = mech["Basic Timings"] or {}
     local mech = rec.Mechanics or {}
    local resource    = mech["Resource Cost"] or {}
     if next(mech) ~= nil then
        addSectionHeader(root, "Mechanics")
 
        if mech.Range ~= nil then
            addRow(root, "Range", tostring(mech.Range))
        end
 
        local areaText = formatArea(mech.Area)
        addRow(root, "Area", areaText)
 
        if mech["Autocast Multiplier"] ~= nil then
            addRow(root, "Autocast multiplier", tostring(mech["Autocast Multiplier"]))
        end
 
        local btText = formatTimingBlock(mech["Basic Timings"])
        addRow(root, "Timing", btText)


    if mech.Range then
        local rcText = formatResourceCost(mech["Resource Cost"])
         addRow(root, "Range", string.format("%.2f", mech.Range))
         addRow(root, "Resource cost", rcText)
    end


    addRow(root, "Cast time", formatBasePer(basicTimings["Cast Time"]))
        local comboText = formatCombo(mech.Combo)
    addRow(root, "Cooldown", formatBasePer(basicTimings["Cooldown"]))
        addRow(root, "Combo", comboText)
    addRow(root, "Duration",  formatBasePer(basicTimings["Duration"]))


    local manaCost = formatManaCost(resource["Mana Cost"])
        local effText = formatMechanicEffects(mech.Effects)
    addRow(root, "Mana cost", manaCost)
        addRow(root, "Special mechanics", effText)
    end


     -- Damage + Scaling
    ------------------------------------------------------------------
     local dmg     = rec.Damage or {}
     -- Damage & Healing
     local mainDmg = formatMainDamage(dmg["Main Damage"])
    ------------------------------------------------------------------
    local scaling = formatScaling(dmg.Scaling)
     local dmg = rec.Damage or {}
     if next(dmg) ~= nil then
        addSectionHeader(root, "Damage and scaling")


    addRow(root, "Main damage", mainDmg)
        if dmg["Healing Present"] then
    addRow(root, "Scaling",     scaling)
            addRow(root, "Healing", "Yes")
        end


    -- Status interactions
        local mainText = formatMainDamage(dmg["Main Damage"])
    local statusApps = formatStatusApplications(rec["Status Applications"])
        addRow(root, "Main damage", mainText)
    addRow(root, "Status applications", statusApps)


    return tostring(root)
        local reflText = formatReflectDamage(dmg["Reflect Damage"])
end
        addRow(root, "Reflect damage", reflText)


local function skillMatchesUser(rec, userName)
        local scaleText = formatScaling(dmg.Scaling)
    if type(rec) ~= "table" or not userName or userName == "" then
        addRow(root, "Scaling", scaleText)
        return false
     end
     end


     local users = rec.Users
    ------------------------------------------------------------------
     if type(users) ~= "table" then
    -- Modifiers
         return false
    ------------------------------------------------------------------
     local modsText = formatModifiers(rec.Modifiers)
     if modsText then
        addSectionHeader(root, "Modifiers")
         addRow(root, "Flags", modsText)
     end
     end


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


     local function listHas(list)
    ------------------------------------------------------------------
        if type(list) ~= "table" then
    -- Events
            return false
    ------------------------------------------------------------------
         end
     local eventsText = formatEvents(rec.Events)
        for _, v in ipairs(list) do
    if eventsText then
            if type(v) == "string" and mw.ustring.lower(v) == userLower then
         addSectionHeader(root, "Events")
                return true
        addRow(root, "Triggers", eventsText)
            end
        end
        return false
     end
     end


     -- Adjust this if you want summons/monsters/events included too
     ------------------------------------------------------------------
     if listHas(users.Classes) then return true end
    -- Notes
    if listHas(users.Summons) then return true end
    ------------------------------------------------------------------
    if listHas(users.Monsters) then return true end
     if type(rec.Notes) == "table" and #rec.Notes > 0 then
     if listHas(users.Events) then return true end
        addSectionHeader(root, "Notes")
        addRow(root, "Notes", table.concat(rec.Notes, "<br />"))
     end


     return false
     return tostring(root)
end
end
----------------------------------------------------------------------
-- Public: list all skills for a given user/class
----------------------------------------------------------------------


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


     -- Preferred explicit param, then unnamed, then fall back to the current page name.
     -- Prefer explicit param, then unnamed, then fall back to the current page name.
     local userName = args.user or args[1]
     local userName = args.user or args[1]
     if not userName or userName == "" then
     if not userName or userName == "" then
Line 358: Line 707:
     end
     end


    -- Container for all skill boxes
     local root = mw.html.create("div")
     local root = mw.html.create("div")
     root:addClass("spiritvale-skill-list")
     root:addClass("spiritvale-skill-list")
    -- Optional heading; comment this out if you prefer to handle headings in wikitext
    -- root:tag("h3"):wikitext("Skills: " .. userName):done()


     for _, rec in ipairs(matches) do
     for _, rec in ipairs(matches) do
        -- Reuse the existing infobox for each skill
         root:wikitext(buildInfobox(rec))
         root:wikitext(buildInfobox(rec))
     end
     end
Line 374: Line 718:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Public entry point
-- Public: single-skill or auto-list dispatcher
----------------------------------------------------------------------
----------------------------------------------------------------------


Line 390: Line 734:
     local rec
     local rec


     -- 1) Prefer display Name (what editors will actually use)
     -- 1) Prefer display Name
     if name and name ~= "" then
     if name and name ~= "" then
         rec = findSkillByName(name)
         rec = findSkillByName(name)
     end
     end


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


     -- 3) If we still don't have a record, decide whether this is:
     -- 3) If still nothing, decide if this is "list mode" or truly unknown.
    --    - a class/user page call (list mode), or
    --    - genuinely an unknown skill.
     if not rec then
     if not rec then
         local pageTitle = mw.title.getCurrentTitle()
         local pageTitle = mw.title.getCurrentTitle()
Line 412: Line 754:
             (not id or id == "")
             (not id or id == "")


         -- Case A: called with *no* args at all – treat as "list skills for this page".
         -- Case A: {{Skill}} with no parameters on a page → list for that page name.
        --  e.g. {{Skill}} on the "Acolyte" page
         if noExplicitArgs then
         if noExplicitArgs then
             return p.listForUser(frame)
             return p.listForUser(frame)
         end
         end


         -- Case B: called with a name that matches the page name, and no ID:
         -- Case B: {{Skill|Acolyte}} on the "Acolyte" page and no id → treat as list.
        --  e.g. {{Skill|Acolyte}} on the "Acolyte" page
         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


         -- Otherwise, this really looks like "unknown skill".
         -- Otherwise, genuinely unknown skill.
         local label = name or id or "?"
         local label = name or id or "?"
         return string.format(
         return string.format(
Line 436: Line 776:
     return buildInfobox(rec)
     return buildInfobox(rec)
end
end


return p
return p

Revision as of 22:14, 12 December 2025

Module:GameSkills

Module:GameSkills renders skill data from Data:skills.json into a reusable infobox-style table.

It is intended to be used via a template (for example Template:Skill) so that skills can be embedded on any page without creating individual skill pages.

This module:

  • Loads data via Module:GameDataGameData.loadSkills().
  • Looks up skills primarily by display "Name" (what editors use), with "Internal Name" as a fallback.
  • Builds a table with only the fields that actually exist for that skill.

Data source

Skill data comes from Data:skills.json, which is a JSON page with this top-level structure (see Module:GameData/doc for full details):

{
  "version": "SpiritVale-0.8.2",
  "schema_version": 1,
  "generated_at": "2025-12-12T17:24:05.807675+00:00",
  "records": [
    {
      "Name": "Some Skill",
      "Internal Name": "SomeSkillInternalId",
      "...": "other fields specific to skills"
    }
  ]
}

Each record is a single skill. 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.

Output

For a given skill, the module renders a table with the CSS class spiritvale-skill-infobox.

Depending on what exists in the JSON record, the table may include:

  • Header row with skill name (and icon, if present).
  • Icon (from "Icon", as a file name like skill-example.webp).
  • Description.
  • Type information:
    • Damage Type
    • Element Type
    • Target Type
    • Cast Type
  • Max Level.
  • Users:
    • Classes
    • Summons
    • Monsters
    • Events
  • Requirements:
    • Required Skills (with required level)
    • Required Weapons
    • Required Stances
  • Mechanics:
    • Range
    • Cast Time / Cooldown / Duration
    • Mana Cost
  • Damage and scaling:
    • Main Damage (base and per-level, where present)
    • Scaling (stat-based contributions)
  • Status interactions:
    • Status Applications (status name, scope, basic duration/chance info)

Rows are only shown if the underlying field exists in the JSON for that skill.


Public interface

The module exposes a single entry point for templates:

GameSkills.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 skill "Name".
  • name – explicit display "Name" of the skill (equivalent to 1).
  • id"Internal Name" of the skill (optional fallback / power use).

Lookup order:

  1. If name or the first unnamed parameter is provided and matches a record’s "Name", that record is used.
  2. Otherwise, if id is provided and matches an "Internal Name", that record is used.
  3. 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 skills found for: GameSkills

or:

No skills found for: GameSkills

Template:Skill

The recommended way to use this module is via a small wrapper template, for example:

Template:Skill
 No skills found for: GameSkills

Typical usage on any page:

Bash
General
DescriptionDelivers a crushing blow with a chance to stun the target.
Max level10
ClassesWarrior, Weaver
MonstersAshrend, Goblin King
Requirements
Required skillsAxe Mastery (Lv.1)
Type
Damage typeMelee
ElementNeutral
TargetEnemy
Cast typeTarget
Mechanics
Autocast multiplier1
TimingEffect Cast Time: 0.24
Resource costMP: Base 3, 1 / Lv
ComboType: Ready, Duration: 4
Damage and scaling
Main damageDamage – Base 1%, 0.25% / Lv, ATK-based
ScalingStrength – 2%, ATK-based
Modifiers
FlagsSpecial: Self Centered
Status effects
AppliesTarget – Stun (Duration Base 3, Chance 0.03 / Lv)

or, explicitly:

Bash
General
DescriptionDelivers a crushing blow with a chance to stun the target.
Max level10
ClassesWarrior, Weaver
MonstersAshrend, Goblin King
Requirements
Required skillsAxe Mastery (Lv.1)
Type
Damage typeMelee
ElementNeutral
TargetEnemy
Cast typeTarget
Mechanics
Autocast multiplier1
TimingEffect Cast Time: 0.24
Resource costMP: Base 3, 1 / Lv
ComboType: Ready, Duration: 4
Damage and scaling
Main damageDamage – Base 1%, 0.25% / Lv, ATK-based
ScalingStrength – 2%, ATK-based
Modifiers
FlagsSpecial: Self Centered
Status effects
AppliesTarget – Stun (Duration Base 3, Chance 0.03 / Lv)

Internal IDs can still be used when needed:

Unknown skill: Bash_InternalId

This keeps page wikitext simple while centralizing all JSON loading and formatting logic inside Lua.


-- Module:GameSkills
--
-- Renders active skill data (from Data:skills.json) into an infobox-style table
-- and can also list all skills for a given user/class.
--
-- Usage (single skill):
--   {{Skill|Heal}}
--   {{Skill|name=Heal}}
--   {{Skill|id=Heal_InternalId}}
--
-- Usage (auto-list on class page, e.g. "Acolyte"):
--   {{Skill}}                  -> lists all Acolyte skills (page name)
--   {{Skill|Acolyte}}          -> same, if no skill literally called "Acolyte"

local GameData = require("Module:GameData")

local p = {}

----------------------------------------------------------------------
-- Internal helpers
----------------------------------------------------------------------

local skillsCache

local function getSkills()
    if not skillsCache then
        skillsCache = GameData.loadSkills()
    end
    return skillsCache
end

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

local function listToText(list, sep)
    if type(list) ~= "table" or #list == 0 then
        return nil
    end
    return table.concat(list, sep or ", ")
end

local function addRow(tbl, label, value)
    if value == nil or value == "" then
        return
    end
    local row = tbl:tag("tr")
    row:tag("th"):wikitext(label):done()
    row:tag("td"):wikitext(value):done()
end

local function addSectionHeader(tbl, label)
    local row = tbl:tag("tr")
    local cell = row:tag("th")
    cell:attr("colspan", 2)
    cell:addClass("spiritvale-infobox-section-header")
    cell:wikitext(label)
end

-- Lookup by Internal Name
local function getSkillById(id)
    if not id or id == "" then
        return nil
    end
    local dataset = getSkills()
    local byId = dataset.byId or {}
    return byId[id]
end

-- Lookup by display Name (for editors)
local function findSkillByName(name)
    if not name or name == "" then
        return nil
    end
    local dataset = getSkills()
    for _, rec in ipairs(dataset.records or {}) do
        if rec["Name"] == name then
            return rec
        end
    end
    return nil
end

----------------------------------------------------------------------
-- Formatting helpers
----------------------------------------------------------------------

local function formatBasePer(block)
    if type(block) ~= "table" then
        return nil
    end
    local parts = {}
    if block.Base ~= nil then
        table.insert(parts, string.format("Base %s", tostring(block.Base)))
    end
    if block["Per Level"] ~= nil then
        table.insert(parts, string.format("%s / Lv", tostring(block["Per Level"])))
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, ", ")
end

local function formatMainDamage(list)
    if type(list) ~= "table" or #list == 0 then
        return nil
    end
    local parts = {}
    for _, d in ipairs(list) do
        if type(d) == "table" then
            local kind = d.Type or "Damage"
            local base = d["Base %"]
            local per  = d["Per Level %"]
            local seg  = kind
            local detail = {}
            if base ~= nil then
                table.insert(detail, string.format("Base %s%%", tostring(base)))
            end
            if per ~= nil then
                table.insert(detail, string.format("%s%% / Lv", tostring(per)))
            end
            if d["ATK-Based"] then
                table.insert(detail, "ATK-based")
            end
            if d["MATK-Based"] then
                table.insert(detail, "MATK-based")
            end
            if #detail > 0 then
                seg = seg .. " – " .. table.concat(detail, ", ")
            end
            table.insert(parts, seg)
        end
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end

local function formatReflectDamage(list)
    if type(list) ~= "table" or #list == 0 then
        return nil
    end
    local parts = {}
    for _, d in ipairs(list) do
        if type(d) == "table" then
            local base = d["Base %"]
            local per  = d["Per Level %"]
            local seg  = "Reflect"
            local detail = {}
            if base ~= nil then
                table.insert(detail, string.format("Base %s%%", tostring(base)))
            end
            if per ~= nil then
                table.insert(detail, string.format("%s%% / Lv", tostring(per)))
            end
            if #detail > 0 then
                seg = seg .. " – " .. table.concat(detail, ", ")
            end
            table.insert(parts, seg)
        end
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end

local function formatScaling(list)
    if type(list) ~= "table" or #list == 0 then
        return nil
    end
    local parts = {}
    for _, s in ipairs(list) do
        if type(s) == "table" then
            local name = s["Scaling Name"] or s["Scaling ID"] or "Unknown"
            local pct  = s.Percent
            local seg  = name
            local detail = {}
            if pct ~= nil then
                table.insert(detail, string.format("%s%%", tostring(pct)))
            end
            if s["ATK-Based"] then
                table.insert(detail, "ATK-based")
            end
            if s["MATK-Based"] then
                table.insert(detail, "MATK-based")
            end
            if #detail > 0 then
                seg = seg .. " – " .. table.concat(detail, ", ")
            end
            table.insert(parts, seg)
        end
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end

local function formatArea(area)
    if type(area) ~= "table" then
        return nil
    end
    local parts = {}
    local size = area["Area Size"]
    if size and size ~= "" then
        table.insert(parts, "Size: " .. tostring(size))
    end
    local dist = area["Area Distance"]
    local eff  = area["Effective Distance"]
    local distText = formatBasePer(dist)
    if distText then
        table.insert(parts, "Distance: " .. distText)
    end
    if eff ~= nil then
        table.insert(parts, string.format("Effective: %s", tostring(eff)))
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end

local function formatTimingBlock(bt)
    if type(bt) ~= "table" then
        return nil
    end
    local parts = {}

    local function add(name, key)
        local block = bt[key]
        local txt = formatBasePer(block)
        if txt then
            table.insert(parts, name .. ": " .. txt)
        end
    end

    add("Cast Time", "Cast Time")
    add("Cooldown", "Cooldown")
    add("Duration", "Duration")

    if bt["Effect Cast Time"] ~= nil then
        table.insert(parts, "Effect Cast Time: " .. tostring(bt["Effect Cast Time"]))
    end
    if bt["Damage Delay"] ~= nil then
        table.insert(parts, "Damage Delay: " .. tostring(bt["Damage Delay"]))
    end
    if bt["Effect Remove Delay"] ~= nil then
        table.insert(parts, "Effect Remove Delay: " .. tostring(bt["Effect Remove Delay"]))
    end

    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end

local function formatResourceCost(rc)
    if type(rc) ~= "table" then
        return nil
    end
    local parts = {}
    local mana = rc["Mana Cost"]
    local hp   = rc["Health Cost"]

    local manaTxt = formatBasePer(mana)
    if manaTxt then
        table.insert(parts, "MP: " .. manaTxt)
    end

    local hpTxt = formatBasePer(hp)
    if hpTxt then
        table.insert(parts, "HP: " .. hpTxt)
    end

    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end

local function formatCombo(combo)
    if type(combo) ~= "table" then
        return nil
    end
    local parts = {}
    if combo.Type then
        table.insert(parts, "Type: " .. tostring(combo.Type))
    end
    if combo.Duration ~= nil then
        table.insert(parts, "Duration: " .. tostring(combo.Duration))
    end
    if combo.Percent ~= nil then
        table.insert(parts, string.format("Bonus: %s%%", tostring(combo.Percent * 100)))
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, ", ")
end

local function formatMechanicEffects(effects)
    if type(effects) ~= "table" then
        return nil
    end
    local parts = {}
    for name, block in pairs(effects) do
        if type(block) == "table" then
            local bp = formatBasePer(block)
            local seg = name
            if bp then
                seg = seg .. " – " .. bp
            end
            table.insert(parts, seg)
        end
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end

local function formatModifiers(mods)
    if type(mods) ~= "table" then
        return nil
    end
    local parts = {}

    local function collect(label, sub)
        if type(sub) ~= "table" then
            return
        end
        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"])
    collect("Combat",   mods["Combat Modifiers"])
    collect("Special",  mods["Special Modifiers"])

    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end

local function formatStatusApplications(list)
    if type(list) ~= "table" or #list == 0 then
        return nil
    end
    local parts = {}
    for _, s in ipairs(list) do
        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 = {}

            local dur = s.Duration
            if type(dur) == "table" then
                local t = formatBasePer(dur)
                if t then
                    table.insert(detail, "Duration " .. t)
                end
            end

            local ch = s.Chance
            if type(ch) == "table" then
                local t = formatBasePer(ch)
                if t then
                    table.insert(detail, "Chance " .. t)
                end
            end

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

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

            table.insert(parts, seg)
        end
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end

local function formatStatusRemoval(list)
    if type(list) ~= "table" or #list == 0 then
        return nil
    end
    local parts = {}
    for _, r in ipairs(list) do
        if type(r) == "table" then
            local names = r["Status Name"]
            local label
            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 seg = label
            if bp then
                seg = seg .. " – " .. bp
            end
            table.insert(parts, seg)
        end
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end

local function formatEvents(list)
    if type(list) ~= "table" or #list == 0 then
        return nil
    end
    local parts = {}
    for _, ev in ipairs(list) do
        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
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end

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

local function skillMatchesUser(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

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

    return false
end

----------------------------------------------------------------------
-- Infobox builder
----------------------------------------------------------------------

local function buildInfobox(rec)
    local root = mw.html.create("table")
    root:addClass("wikitable spiritvale-skill-infobox")

    -- Header: icon + name
    local icon  = rec.Icon
    local title = rec.Name or rec["Internal Name"] or "Unknown Skill"

    local header = root:tag("tr")
    local headerCell = header:tag("th")
    headerCell:attr("colspan", 2)

    local titleText = ""
    if icon and icon ~= "" then
        titleText = string.format("[[File:%s|64px|link=]] ", icon)
    end
    titleText = titleText .. title
    headerCell:wikitext(titleText)

    ------------------------------------------------------------------
    -- General
    ------------------------------------------------------------------
    addSectionHeader(root, "General")
    addRow(root, "Description", rec.Description)
    addRow(root, "Max level", rec["Max Level"] and tostring(rec["Max Level"]))

    local users = rec.Users or {}
    addRow(root, "Classes",  listToText(users.Classes))
    addRow(root, "Summons",  listToText(users.Summons))
    addRow(root, "Monsters", listToText(users.Monsters))
    addRow(root, "Events",   listToText(users.Events))

    ------------------------------------------------------------------
    -- Requirements
    ------------------------------------------------------------------
    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")

        if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
            local skillParts = {}
            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

        addRow(root, "Required weapons", listToText(req["Required Weapons"]))
        addRow(root, "Required stances", listToText(req["Required Stances"]))
    end

    ------------------------------------------------------------------
    -- Type
    ------------------------------------------------------------------
    local typeBlock = rec.Type or {}
    if next(typeBlock) ~= nil then
        addSectionHeader(root, "Type")

        local dt = typeBlock["Damage Type"]
        if type(dt) == "table" and dt.Name then
            addRow(root, "Damage type", dt.Name)
        end

        local et = typeBlock["Element Type"]
        if type(et) == "table" and et.Name then
            addRow(root, "Element", et.Name)
        end

        local tt = typeBlock["Target Type"]
        if type(tt) == "table" and tt.Name then
            addRow(root, "Target", tt.Name)
        end

        local ct = typeBlock["Cast Type"]
        if type(ct) == "table" and ct.Name then
            addRow(root, "Cast type", ct.Name)
        end
    end

    ------------------------------------------------------------------
    -- Mechanics
    ------------------------------------------------------------------
    local mech = rec.Mechanics or {}
    if next(mech) ~= nil then
        addSectionHeader(root, "Mechanics")

        if mech.Range ~= nil then
            addRow(root, "Range", tostring(mech.Range))
        end

        local areaText = formatArea(mech.Area)
        addRow(root, "Area", areaText)

        if mech["Autocast Multiplier"] ~= nil then
            addRow(root, "Autocast multiplier", tostring(mech["Autocast Multiplier"]))
        end

        local btText = formatTimingBlock(mech["Basic Timings"])
        addRow(root, "Timing", btText)

        local rcText = formatResourceCost(mech["Resource Cost"])
        addRow(root, "Resource cost", rcText)

        local comboText = formatCombo(mech.Combo)
        addRow(root, "Combo", comboText)

        local effText = formatMechanicEffects(mech.Effects)
        addRow(root, "Special mechanics", effText)
    end

    ------------------------------------------------------------------
    -- Damage & Healing
    ------------------------------------------------------------------
    local dmg = rec.Damage or {}
    if next(dmg) ~= nil then
        addSectionHeader(root, "Damage and scaling")

        if dmg["Healing Present"] then
            addRow(root, "Healing", "Yes")
        end

        local mainText = formatMainDamage(dmg["Main Damage"])
        addRow(root, "Main damage", mainText)

        local reflText = formatReflectDamage(dmg["Reflect Damage"])
        addRow(root, "Reflect damage", reflText)

        local scaleText = formatScaling(dmg.Scaling)
        addRow(root, "Scaling", scaleText)
    end

    ------------------------------------------------------------------
    -- Modifiers
    ------------------------------------------------------------------
    local modsText = formatModifiers(rec.Modifiers)
    if modsText then
        addSectionHeader(root, "Modifiers")
        addRow(root, "Flags", modsText)
    end

    ------------------------------------------------------------------
    -- Status
    ------------------------------------------------------------------
    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

    ------------------------------------------------------------------
    -- Events
    ------------------------------------------------------------------
    local eventsText = formatEvents(rec.Events)
    if eventsText then
        addSectionHeader(root, "Events")
        addRow(root, "Triggers", eventsText)
    end

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

    return tostring(root)
end

----------------------------------------------------------------------
-- Public: list all skills for a given user/class
----------------------------------------------------------------------

function p.listForUser(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
        return "<strong>No user name provided to Skill list.</strong>"
    end

    local dataset = getSkills()
    local matches = {}

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

    if #matches == 0 then
        return string.format(
            "<strong>No skills found for:</strong> %s",
            mw.text.nowiki(userName)
        )
    end

    local root = mw.html.create("div")
    root:addClass("spiritvale-skill-list")

    for _, rec in ipairs(matches) do
        root:wikitext(buildInfobox(rec))
    end

    return tostring(root)
end

----------------------------------------------------------------------
-- Public: single-skill or auto-list dispatcher
----------------------------------------------------------------------

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

    -- Allow three styles:
    --   {{Skill|Bash}}              -> args[1] = "Bash"  (Name)
    --   {{Skill|name=Bash}}         -> args.name = "Bash"
    --   {{Skill|id=Bash_Internal}}  -> args.id = "Bash_Internal"
    local raw1 = args[1]
    local name = args.name or raw1
    local id   = args.id

    local rec

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

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

    -- 3) If still nothing, decide if this is "list mode" or truly unknown.
    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 == "")

        -- Case A: {{Skill}} with no parameters on a page → list for that page name.
        if noExplicitArgs then
            return p.listForUser(frame)
        end

        -- Case B: {{Skill|Acolyte}} on the "Acolyte" page and no id → treat as list.
        if name and name ~= "" and name == pageName and (not id or id == "") then
            return p.listForUser(frame)
        end

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

    -- Normal single-skill behavior
    return buildInfobox(rec)
end

return p