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

Revision as of 21:39, 12 December 2025 by Eviand (talk | contribs)

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:

Unknown skill: Bash

or, explicitly:

Unknown skill: Bash

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 skill data (from Data:skills.json) into an infobox/table.
-- Data is loaded via Module:GameData.
--
-- Supported usage patterns (via Template:Skill):
--   {{Skill|Bash}}                  -- uses display Name (recommended)
--   {{Skill|name=Bash}}             -- explicit name
--   {{Skill|id=Bash_InternalId}}    -- internal ID (power use)

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 type(list) ~= "table" 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 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 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 type(damageTable) ~= "table" or #damageTable == 0 then
        return nil
    end

    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 type(scalingList) ~= "table" 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 type(list) ~= "table" 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
            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

-- Lookup by internal ID (for tools / power use)
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

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

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

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

    return tostring(root)
end

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

    -- Adjust this if you want summons/monsters/events included too
    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

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

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

    -- Container for all skill boxes
    local root = mw.html.create("div")
    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
        -- Reuse the existing infobox for each skill
        root:wikitext(buildInfobox(rec))
    end

    return tostring(root)
end

----------------------------------------------------------------------
-- Public entry point
----------------------------------------------------------------------

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 (what editors will actually use)
    if name and name ~= "" then
        rec = findSkillByName(name)
    end

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

    -- 3) If we still don't have a record, decide whether this is:
    --    - a class/user page call (list mode), or
    --    - genuinely an unknown skill.
    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: called with *no* args at all – treat as "list skills for this page".
        --   e.g. {{Skill}} on the "Acolyte" page
        if noExplicitArgs then
            return p.listForUser(frame)
        end

        -- Case B: called with a name that matches the page name, and no ID:
        --   e.g. {{Skill|Acolyte}} on the "Acolyte" page
        if name and name ~= "" and name == pageName and (not id or id == "") then
            return p.listForUser(frame)
        end

        -- Otherwise, this really looks like "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