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
Created page with "-- Module:GameSkills -- -- Renders skill data (from Data:skills.json) into a nice infobox/table. -- Data is loaded via Module:GameData. -- -- Typical usage (via Template:Skill): -- {{Skill|id=Dispell}} -- {{Skill|name=Absolution}} -- slower fallback by display name local GameData = require('Module:GameData') local p = {} ---------------------------------------------------------------------- -- Internal helpers: lookups --------------------------------------------..."
 
No edit summary
Line 1: Line 1:
-- Module:GameSkills
local GameData = require("Module:GameData")
--
-- Renders skill data (from Data:skills.json) into a nice infobox/table.
-- Data is loaded via Module:GameData.
--
-- Typical usage (via Template:Skill):
--  {{Skill|id=Dispell}}
--  {{Skill|name=Absolution}}  -- slower fallback by display name
 
local GameData = require('Module:GameData')


local p = {}
local p = {}


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Internal helpers: lookups
-- Internal helpers
----------------------------------------------------------------------
----------------------------------------------------------------------


local function getDataset()
local skillsCache
    return GameData.loadSkills()
end


-- Fast lookup by Internal Name
local function getSkills()
local function getSkillById(id)
     if not skillsCache then
     if not id or id == '' then
         skillsCache = GameData.loadSkills()
         return nil
     end
     end
    local dataset = getDataset()
     return skillsCache
    local byId = dataset.byId or {}
     return byId[id]
end
end


-- Slower fallback: lookup by display Name
local function getArgs(frame)
local function findSkillByName(name)
     -- Prefer parent template args if present (usual #invoke pattern)
     if not name or name == '' then
     local parent = frame:getParent()
        return nil
     if parent then
    end
        return parent.args
     local dataset = getDataset()
     for _, rec in ipairs(dataset.records or {}) do
        if rec["Name"] == name then
            return rec
        end
     end
     end
     return nil
     return frame.args
end
end


----------------------------------------------------------------------
local function listToText(list)
-- Formatting helpers
     if not list or #list == 0 then
----------------------------------------------------------------------
 
local function formatTypeLine(t)
     if type(t) ~= 'table' then
         return nil
         return nil
     end
     end
    return table.concat(list, ", ")
end


    local parts = {}
local function addRow(tbl, label, value)
 
     if value == nil or value == "" then
     local dmg = t["Damage Type"]
         return
    if dmg and dmg["Name"] then
         table.insert(parts, dmg["Name"])
     end
     end
    local row = tbl:tag("tr")
    row:tag("th"):wikitext(label):done()
    row:tag("td"):wikitext(value):done()
end


    local elem = t["Element Type"]
local function formatBasePer(block)
     if elem and elem["Name"] then
     if not block or type(block) ~= "table" then
         table.insert(parts, elem["Name"])
         return nil
     end
     end
    local base = block.Base
    local per  = block["Per Level"]


    local target = t["Target Type"]
     if base and per then
     if target and target["Name"] then
         return string.format("%.2f (%.2f / Lv)", base, per)
         table.insert(parts, target["Name"])
     elseif base then
     end
        return string.format("%.2f", base)
 
     elseif per then
    local cast = t["Cast Type"]
         return string.format("%.2f / Lv", per)
     if cast and cast["Name"] then
     else
         table.insert(parts, cast["Name"])
     end
 
    if #parts == 0 then
         return nil
         return nil
     end
     end
    return table.concat(parts, " • ")
end
end


local function formatUsers(users)
local function formatManaCost(block)
     if type(users) ~= 'table' then
     if not block or type(block) ~= "table" then
         return nil
         return nil
     end
     end


     local chunks = {}
     local base = block.Base
 
     local per  = block["Per Level"]
    local classes = users["Classes"]
    if type(classes) == 'table' and #classes > 0 then
        table.insert(chunks, "Classes: " .. table.concat(classes, ", "))
    end
 
     local summons = users["Summons"]
    if type(summons) == 'table' and #summons > 0 then
        table.insert(chunks, "Summons: " .. table.concat(summons, ", "))
    end


    local monsters = users["Monsters"]
     if base and per then
     if type(monsters) == 'table' and #monsters > 0 then
         return string.format("%.0f (%.0f / Lv)", base, per)
         table.insert(chunks, "Monsters: " .. table.concat(monsters, ", "))
    elseif base then
     end
        return string.format("%.0f", base)
 
     elseif per then
    -- (Users.Events exists in the data, but we skip it for now – mostly internal hooks.)
        return string.format("%.0f / Lv", per)
 
     else
     if #chunks == 0 then
         return nil
         return nil
     end
     end
    return table.concat(chunks, "<br />")
end
end


local function formatArea(mech)
local function formatMainDamage(damageTable)
     if type(mech) ~= 'table' then
     if not damageTable or #damageTable == 0 then
        return nil
    end
 
    local area = mech["Area"]
    if type(area) ~= 'table' then
         return nil
         return nil
     end
     end


    -- Current schema only has one entry, but we’ll still handle arrays.
     local parts = {}
     local parts = {}
    for _, entry in ipairs(damageTable) do
        local base = entry["Base %"]
        local per  = entry["Per Level %"]
        local txt


    local size = area["Area Size"]
        if base and per then
    if size then
            txt = string.format("Base %.2f, +%.2f / Lv", base, per)
         table.insert(parts, tostring(size))
        elseif base then
    end
            txt = string.format("Base %.2f", base)
         elseif per then
            txt = string.format("+%.2f / Lv", per)
        end


    local eff = area["Effective Distance"]
        if txt then
    if eff then
            table.insert(parts, txt)
        table.insert(parts, string.format("Radius: %s", eff))
        end
     end
     end


Line 138: Line 106:
         return nil
         return nil
     end
     end
 
     return table.concat(parts, "; ")
     return table.concat(parts, " ")
end
end


local function formatCastAndCooldown(mech)
local function formatScaling(scalingList)
     if type(mech) ~= 'table' then
     if not scalingList or #scalingList == 0 then
        return nil, nil, nil
    end
 
    local basic = mech["Basic Timings"]
    if type(basic) ~= 'table' then
        return nil, nil, nil
    end
 
    local cast, cd, dur
 
    local castBlock = basic["Cast Time"]
    if type(castBlock) == 'table' and castBlock["Base"] then
        cast = tostring(castBlock["Base"]) .. " s"
        if castBlock["Per Level"] and castBlock["Per Level"] ~= 0 then
            local per = castBlock["Per Level"]
            local sign = per > 0 and "+" or ""
            cast = cast .. string.format(" (%s%g per level)", sign, per)
        end
    end
 
    local cdBlock = basic["Cooldown"]
    if type(cdBlock) == 'table' and cdBlock["Base"] then
        cd = tostring(cdBlock["Base"]) .. " s"
        if cdBlock["Per Level"] and cdBlock["Per Level"] ~= 0 then
            local per = cdBlock["Per Level"]
            local sign = per > 0 and "+" or ""
            cd = cd .. string.format(" (%s%g per level)", sign, per)
        end
    end
 
    local durBlock = basic["Duration"]
    if type(durBlock) == 'table' and durBlock["Base"] then
        dur = tostring(durBlock["Base"]) .. " s"
        if durBlock["Per Level"] and durBlock["Per Level"] ~= 0 then
            local per = durBlock["Per Level"]
            local sign = per > 0 and "+" or ""
            dur = dur .. string.format(" (%s%g per level)", sign, per)
        end
    end
 
    return cast, cd, dur
end
 
local function formatResourceCost(mech)
    if type(mech) ~= 'table' then
        return nil
    end
 
    local cost = mech["Resource Cost"]
    if type(cost) ~= 'table' then
         return nil
         return nil
     end
     end


     local parts = {}
     local parts = {}
 
     for _, s in ipairs(scalingList) do
     local mana = cost["Mana Cost"]
         local name = s["Scaling Name"] or s["Scaling ID"] or "Unknown"
    if type(mana) == 'table' then
         local pct = s.Percent
         local base = mana["Base"]
         if pct then
        local per  = mana["Per Level"]
             table.insert(parts, string.format("%s: %.2f", name, pct))
        if base and per then
         else
            table.insert(parts, string.format("Mana: %s + %s per level", base, per))
             table.insert(parts, name)
        elseif base then
            table.insert(parts, string.format("Mana: %s", base))
        end
    end
 
    local hp = cost["Health Cost"]
    if type(hp) == 'table' then
        local base = hp["Base"]
         local per = hp["Per Level"]
         if base and per then
             table.insert(parts, string.format("HP: %s + %s per level", base, per))
         elseif base then
             table.insert(parts, string.format("HP: %s", base))
         end
         end
     end
     end
Line 224: Line 128:
         return nil
         return nil
     end
     end
 
     return table.concat(parts, ", ")
     return table.concat(parts, "<br />")
end
end


local function formatStatusesApplied(rec)
local function formatStatusApplications(list)
    local apps = rec["Status Applications"]
     if not list or #list == 0 then
     if type(apps) ~= 'table' or #apps == 0 then
         return nil
         return nil
     end
     end


     local names = {}
     local parts = {}
     for _, entry in ipairs(apps) do
     for _, s in ipairs(list) do
         if type(entry) == 'table' then
         local scope  = s.Scope or "Target"
            local n = entry["Status Name"]
        local name  = s["Status Name"] or s["Status ID"] or "Unknown status"
            if type(n) == 'string' then
        local dur    = s.Duration and s.Duration.Base
                table.insert(names, n)
         local chance = s.Chance and s.Chance.Base
            end
         end
    end


    if #names == 0 then
        local seg = name
        return nil
    end


    return table.concat(names, ", ")
        if scope and scope ~= "" then
end
            seg = scope .. ": " .. seg
        end


local function formatStatusesRemoved(rec)
        local detailParts = {}
    local entries = rec["Status Removal"]
        if dur then
    if type(entries) ~= 'table' or #entries == 0 then
            table.insert(detailParts, string.format("Dur %.2f", dur))
         return nil
        end
    end
        if chance then
            -- Stored as 0–1; we’ll keep it raw to avoid guessing units.
            table.insert(detailParts, string.format("Chance %.2f", chance))
         end


    local names = {}
         if #detailParts > 0 then
    for _, entry in ipairs(entries) do
             seg = seg .. " (" .. table.concat(detailParts, ", ") .. ")"
         if type(entry) == 'table' then
             local list = entry["Status Name"]
            if type(list) == 'table' then
                for _, n in ipairs(list) do
                    if type(n) == 'string' then
                        table.insert(names, n)
                    end
                end
            end
         end
         end
    end


    if #names == 0 then
         table.insert(parts, seg)
         return nil
     end
     end


     return table.concat(names, ", ")
     return table.concat(parts, "; ")
end
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Infobox builder
-- Public: infobox
----------------------------------------------------------------------
----------------------------------------------------------------------


local function buildInfobox(rec)
function p.infobox(frame)
     local box = mw.html.create('table')
     local args = getArgs(frame)
        :addClass('sv-skill-infobox')
    local id  = args.id or args[1]


     local name = rec["Name"] or rec["Internal Name"] or "Unknown Skill"
     if not id or id == "" then
        return "[[Category:Pages with missing skill id]]"
    end


     -- Header
     local dataset = getSkills()
    box:tag('tr')
    local rec    = dataset.byId[id]
        :tag('th')
            :attr('colspan', 2)
            :addClass('sv-skill-infobox-title')
            :wikitext(name)
        :done()


     -- Icon
     if not rec then
    local icon = rec["Icon"]
         return "[[Category:Pages with unknown skill id|" .. mw.text.nowiki(id) .. "]]"
    if icon and icon ~= "" then
         box:tag('tr')
            :tag('td')
                :attr('colspan', 2)
                :addClass('sv-skill-infobox-icon')
                :wikitext(string.format('[[File:%s|128px|center]]', icon))
            :done()
     end
     end


    -- Description
     local root = mw.html.create("table")
     local desc = rec["Description"]
     root:addClass("wikitable spiritvale-skill-infobox")
     if desc and desc ~= "" then
        box:tag('tr')
            :tag('th'):wikitext('Description'):done()
            :tag('td'):wikitext(desc):done()
    end


     -- Type line
     -- Header: icon + name
     local typeLine = formatTypeLine(rec["Type"])
     local icon  = rec.Icon
     if typeLine then
     local title = rec.Name or id
        box:tag('tr')
            :tag('th'):wikitext('Type'):done()
            :tag('td'):wikitext(typeLine):done()
    end


    -- Max Level
     local header = root:tag("tr")
     local maxLevel = rec["Max Level"]
    local headerCell = header:tag("th")
    if maxLevel then
    headerCell:attr("colspan", 2)
        box:tag('tr')
            :tag('th'):wikitext('Max Level'):done()
            :tag('td'):wikitext(tostring(maxLevel)):done()
    end


    -- Users
     local titleText = ""
     local usersText = formatUsers(rec["Users"])
     if icon and icon ~= "" then
     if usersText then
         titleText = string.format("[[File:%s|64px|link=]] ", icon)
         box:tag('tr')
            :tag('th'):wikitext('Users'):done()
            :tag('td'):wikitext(usersText):done()
     end
     end
    titleText = titleText .. (title or id)
    headerCell:wikitext(titleText)


     -- Mechanics
     ------------------------------------------------------------------
     local mech = rec["Mechanics"]
     -- Basic info
     if type(mech) == 'table' then
    ------------------------------------------------------------------
        if mech["Range"] then
    addRow(root, "Description", rec.Description)
            box:tag('tr')
     addRow(root, "Max level", rec["Max Level"] and tostring(rec["Max Level"]))
                :tag('th'):wikitext('Range'):done()
                :tag('td'):wikitext(tostring(mech["Range"])):done()
        end


        local areaText = formatArea(mech)
    ------------------------------------------------------------------
        if areaText then
    -- Users
            box:tag('tr')
    ------------------------------------------------------------------
                :tag('th'):wikitext('Area'):done()
    local users = rec.Users or {}
                :tag('td'):wikitext(areaText):done()
    addRow(root, "Classes",  listToText(users.Classes))
        end
    addRow(root, "Summons",  listToText(users.Summons))
    addRow(root, "Monsters", listToText(users.Monsters))
    addRow(root, "Events",  listToText(users.Events))


        local cast, cd, dur = formatCastAndCooldown(mech)
    ------------------------------------------------------------------
        if cast then
    -- Requirements
            box:tag('tr')
    ------------------------------------------------------------------
                :tag('th'):wikitext('Cast Time'):done()
    local req = rec.Requirements or {}
                :tag('td'):wikitext(cast):done()
        end
        if cd then
            box:tag('tr')
                :tag('th'):wikitext('Cooldown'):done()
                :tag('td'):wikitext(cd):done()
        end
        if dur then
            box:tag('tr')
                :tag('th'):wikitext('Duration'):done()
                :tag('td'):wikitext(dur):done()
        end


         local cost = formatResourceCost(mech)
    if req["Required Skills"] and #req["Required Skills"] > 0 then
        if cost then
         local skillParts = {}
            box:tag('tr')
        for _, rs in ipairs(req["Required Skills"]) do
                :tag('th'):wikitext('Cost'):done()
            local name  = rs["Skill Name"] or rs["Skill ID"] or "Unknown"
                 :tag('td'):wikitext(cost):done()
            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
         end
        addRow(root, "Required skills", table.concat(skillParts, ", "))
     end
     end


     -- Statuses
     addRow(root, "Required weapons", listToText(req["Required Weapons"]))
     local applied = formatStatusesApplied(rec)
    addRow(root, "Required stances", listToText(req["Required Stances"]))
     if applied then
 
        box:tag('tr')
    ------------------------------------------------------------------
            :tag('th'):wikitext('Statuses Applied'):done()
    -- Type
            :tag('td'):wikitext(applied):done()
    ------------------------------------------------------------------
     end
     local t = rec.Type or {}
     local damageType = t["Damage Type"]  and t["Damage Type"].Name
    local element    = t["Element Type"] and t["Element Type"].Name
    local target    = t["Target Type"]  and t["Target Type"].Name
     local castType  = t["Cast Type"]    and t["Cast Type"].Name


     local removed = formatStatusesRemoved(rec)
     addRow(root, "Damage type", damageType)
     if removed then
     addRow(root, "Element",    element)
        box:tag('tr')
    addRow(root, "Targeting",  target)
            :tag('th'):wikitext('Statuses Removed'):done()
    addRow(root, "Cast type",  castType)
            :tag('td'):wikitext(removed):done()
    end


     return tostring(box)
     ------------------------------------------------------------------
end
    -- Mechanics
    ------------------------------------------------------------------
    local mech        = rec.Mechanics or {}
    local basicTimings = mech["Basic Timings"] or {}
    local resource    = mech["Resource Cost"] or {}


----------------------------------------------------------------------
    if mech.Range then
-- Public entry point
        addRow(root, "Range", string.format("%.2f", mech.Range))
----------------------------------------------------------------------
    end


function p.infobox(frame)
    addRow(root, "Cast time", formatBasePer(basicTimings["Cast Time"]))
     -- Support both direct #invoke and wrapper templates
     addRow(root, "Cooldown",  formatBasePer(basicTimings["Cooldown"]))
     local parent = frame:getParent()
     addRow(root, "Duration",  formatBasePer(basicTimings["Duration"]))
    local args = parent and parent.args or frame.args


     local id  = args.id or args[1]
     local manaCost = formatManaCost(resource["Mana Cost"])
     local name = args.name
     addRow(root, "Mana cost", manaCost)


     local rec = nil
    ------------------------------------------------------------------
    -- Damage + Scaling
    ------------------------------------------------------------------
     local dmg    = rec.Damage or {}
    local mainDmg = formatMainDamage(dmg["Main Damage"])
    local scaling = formatScaling(dmg.Scaling)


     if id and id ~= '' then
     addRow(root, "Main damage", mainDmg)
        rec = getSkillById(id)
     addRow(root, "Scaling",    scaling)
     end


     if not rec and name and name ~= '' then
     ------------------------------------------------------------------
        rec = findSkillByName(name)
    -- Status interactions
     end
    ------------------------------------------------------------------
    local statusApps = formatStatusApplications(rec["Status Applications"])
     addRow(root, "Status applications", statusApps)


     if not rec then
     -- We could add Status Removal here later if you want it visible.
        local label = id or name or "?"
        return string.format('<strong>Unknown skill: %s</strong>', label)
    end


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


return p
return p

Revision as of 19:41, 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):

or:


Template:Skill

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

Template:Skill

Typical usage on any page:

Lua error at line 70: bad argument #3 to 'format' (number expected, got table).

or, explicitly:

Internal IDs can still be used when needed:

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


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)
    -- Prefer parent template args if present (usual #invoke pattern)
    local parent = frame:getParent()
    if parent then
        return parent.args
    end
    return frame.args
end

local function listToText(list)
    if not list or #list == 0 then
        return nil
    end
    return table.concat(list, ", ")
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 formatBasePer(block)
    if not block or type(block) ~= "table" then
        return nil
    end
    local base = block.Base
    local per  = block["Per Level"]

    if base and per then
        return string.format("%.2f (%.2f / Lv)", base, per)
    elseif base then
        return string.format("%.2f", base)
    elseif per then
        return string.format("%.2f / Lv", per)
    else
        return nil
    end
end

local function formatManaCost(block)
    if not block or type(block) ~= "table" then
        return nil
    end

    local base = block.Base
    local per  = block["Per Level"]

    if base and per then
        return string.format("%.0f (%.0f / Lv)", base, per)
    elseif base then
        return string.format("%.0f", base)
    elseif per then
        return string.format("%.0f / Lv", per)
    else
        return nil
    end
end

local function formatMainDamage(damageTable)
    if not damageTable or #damageTable == 0 then
        return nil
    end

    -- Current schema only has one entry, but we’ll still handle arrays.
    local parts = {}
    for _, entry in ipairs(damageTable) do
        local base = entry["Base %"]
        local per  = entry["Per Level %"]
        local txt

        if base and per then
            txt = string.format("Base %.2f, +%.2f / Lv", base, per)
        elseif base then
            txt = string.format("Base %.2f", base)
        elseif per then
            txt = string.format("+%.2f / Lv", per)
        end

        if txt then
            table.insert(parts, txt)
        end
    end

    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "; ")
end

local function formatScaling(scalingList)
    if not scalingList or #scalingList == 0 then
        return nil
    end

    local parts = {}
    for _, s in ipairs(scalingList) do
        local name = s["Scaling Name"] or s["Scaling ID"] or "Unknown"
        local pct  = s.Percent
        if pct then
            table.insert(parts, string.format("%s: %.2f", name, pct))
        else
            table.insert(parts, name)
        end
    end

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

local function formatStatusApplications(list)
    if not list or #list == 0 then
        return nil
    end

    local parts = {}
    for _, s in ipairs(list) do
        local scope  = s.Scope or "Target"
        local name   = s["Status Name"] or s["Status ID"] or "Unknown status"
        local dur    = s.Duration and s.Duration.Base
        local chance = s.Chance and s.Chance.Base

        local seg = name

        if scope and scope ~= "" then
            seg = scope .. ": " .. seg
        end

        local detailParts = {}
        if dur then
            table.insert(detailParts, string.format("Dur %.2f", dur))
        end
        if chance then
            -- Stored as 0–1; we’ll keep it raw to avoid guessing units.
            table.insert(detailParts, string.format("Chance %.2f", chance))
        end

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

        table.insert(parts, seg)
    end

    return table.concat(parts, "; ")
end

----------------------------------------------------------------------
-- Public: infobox
----------------------------------------------------------------------

function p.infobox(frame)
    local args = getArgs(frame)
    local id   = args.id or args[1]

    if not id or id == "" then
        return "[[Category:Pages with missing skill id]]"
    end

    local dataset = getSkills()
    local rec     = dataset.byId[id]

    if not rec then
        return "[[Category:Pages with unknown skill id|" .. mw.text.nowiki(id) .. "]]"
    end

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

    -- Header: icon + name
    local icon  = rec.Icon
    local title = rec.Name or id

    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 or id)
    headerCell:wikitext(titleText)

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

    ------------------------------------------------------------------
    -- Users
    ------------------------------------------------------------------
    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 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"]))

    ------------------------------------------------------------------
    -- Type
    ------------------------------------------------------------------
    local t = rec.Type or {}
    local damageType = t["Damage Type"]  and t["Damage Type"].Name
    local element    = t["Element Type"] and t["Element Type"].Name
    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)
    addRow(root, "Element",     element)
    addRow(root, "Targeting",   target)
    addRow(root, "Cast type",   castType)

    ------------------------------------------------------------------
    -- Mechanics
    ------------------------------------------------------------------
    local mech         = rec.Mechanics or {}
    local basicTimings = mech["Basic Timings"] or {}
    local resource     = mech["Resource Cost"] or {}

    if mech.Range then
        addRow(root, "Range", string.format("%.2f", mech.Range))
    end

    addRow(root, "Cast time", formatBasePer(basicTimings["Cast Time"]))
    addRow(root, "Cooldown",  formatBasePer(basicTimings["Cooldown"]))
    addRow(root, "Duration",  formatBasePer(basicTimings["Duration"]))

    local manaCost = formatManaCost(resource["Mana Cost"])
    addRow(root, "Mana cost", manaCost)

    ------------------------------------------------------------------
    -- Damage + Scaling
    ------------------------------------------------------------------
    local dmg     = rec.Damage or {}
    local mainDmg = formatMainDamage(dmg["Main Damage"])
    local scaling = formatScaling(dmg.Scaling)

    addRow(root, "Main damage", mainDmg)
    addRow(root, "Scaling",     scaling)

    ------------------------------------------------------------------
    -- Status interactions
    ------------------------------------------------------------------
    local statusApps = formatStatusApplications(rec["Status Applications"])
    addRow(root, "Status applications", statusApps)

    -- We could add Status Removal here later if you want it visible.

    return tostring(root)
end

return p