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:44, 12 December 2025 by Eviand (talk | contribs)

Module:GamePassives

Module:GamePassives renders passive skill data from Data:passives.json into a reusable infobox-style table.

It is intended to be used via a template (for example Template:Passive) so that passives can be embedded on any page without creating individual pages for each passive.

This module:

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

Data source

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

{
  "version": "SpiritVale-0.9.3",
  "schema_version": 1,
  "generated_at": "2025-12-12T17:24:05.784284+00:00",
  "records": [
    {
      "Name": "Honed Blade",
      "Internal Name": "CritMastery",
      "...": "other fields specific to passives"
    }
  ]
}

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

Common fields include:

  • Icon
  • Description
  • Max Level
  • Users (e.g. Classes)
  • Requirements
  • Passive Effects
  • Status Applications
  • Notes

Output

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

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

  • Header row with passive name (and icon, if present).
  • Description.
  • Max level.
  • Users:
    • Classes
    • Summons
    • Monsters
    • Events
  • Requirements:
    • Required Skills (with required level)
    • Required Weapons
    • Required Stances
  • Passive effects:
    • Each entry from "Passive Effects" (Type name, optional Expression, Base and Per Level where present)
  • Status interactions:
    • Status Applications (if any)
  • Notes:
    • Any entries from "Notes" (one per line)

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


Public interface

The module exposes a single entry point for templates:

GamePassives.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 passive "Name".
  • name – explicit display "Name" of the passive (equivalent to 1).
  • id"Internal Name" of the passive (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 passives found for: GamePassives

or:

No passives found for: GamePassives

Template:Passive

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

Template:Passive
 No passives found for: GamePassives

Typical usage on any page:

Honed Blade
DescriptionA life of combat has perfected your edge, each critical strike hits with ruthless efficiency.
Max level10
ClassesWarrior
Passive effectsCritical Damage – 3.00 / Lv

or, explicitly:

Honed Blade
DescriptionA life of combat has perfected your edge, each critical strike hits with ruthless efficiency.
Max level10
ClassesWarrior
Passive effectsCritical Damage – 3.00 / Lv

Internal IDs can still be used when needed:

Honed Blade
DescriptionA life of combat has perfected your edge, each critical strike hits with ruthless efficiency.
Max level10
ClassesWarrior
Passive effectsCritical Damage – 3.00 / Lv

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


-- Module:GamePassives
--
-- Renders passive skill data (from Data:passives.json) into an infobox-style table.
-- Data is loaded via Module:GameData.
--
-- Supported usage patterns (via Template:Passive):
--   {{Passive|Honed Blade}}              -> uses display Name (recommended)
--   {{Passive|name=Honed Blade}}         -> explicit Name
--   {{Passive|id=CritMastery}}           -> Internal Name (power use)

local GameData = require("Module:GameData")

local p = {}

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

local passivesCache

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

local function getArgs(frame)
    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

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

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

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

local function formatPassiveEffects(list)
    if type(list) ~= "table" or #list == 0 then
        return nil
    end

    local parts = {}

    for _, eff in ipairs(list) do
        if type(eff) == "table" then
            local typeName = eff.Type and eff.Type.Name or eff.ID or "Unknown"
            local value    = eff.Value or {}

            local expr = value.Expression
            local base = value.Base
            local per  = value["Per Level"]

            local seg = typeName

            if expr and expr ~= "" then
                seg = seg .. string.format(" (%s)", expr)
            end

            local detail = {}
            if base then
                table.insert(detail, string.format("Base %.2f", base))
            end
            if per then
                table.insert(detail, string.format("%.2f / Lv", 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

    -- One per line
    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 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
    end

    if #parts == 0 then
        return nil
    end

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

local function formatNotes(notes)
    if type(notes) ~= "table" or #notes == 0 then
        return nil
    end
    return table.concat(notes, "<br />")
end

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

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

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

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

    ------------------------------------------------------------------
    -- Passive effects
    ------------------------------------------------------------------
    local effectsText = formatPassiveEffects(rec["Passive Effects"])
    addRow(root, "Passive effects", effectsText)

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

    ------------------------------------------------------------------
    -- Notes
    ------------------------------------------------------------------
    local notesText = formatNotes(rec.Notes)
    addRow(root, "Notes", notesText)

    return tostring(root)
end

local function passiveMatchesUser(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 *only* want Classes.
    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 Passive list.</strong>"
    end

    local dataset = getPassives()
    local matches = {}

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

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

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

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

    return tostring(root)
end

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

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

    -- Allow:
    --   {{Passive|Honed Blade}}           -> args[1] = "Honed Blade" (Name)
    --   {{Passive|name=Honed Blade}}      -> args.name
    --   {{Passive|id=CritMastery}}        -> args.id (Internal Name)
    local raw1 = args[1]
    local name = args.name or raw1
    local id   = args.id

    local rec

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

    -- 2) Fallback: Internal Name if explicitly given
    if not rec and id and id ~= "" then
        rec = getPassiveById(id)
    end

    -- 3) If still no record, decide: list mode or unknown passive?
    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: {{Passive}} with no parameters on a page → list for that page name.
        if noExplicitArgs then
            return p.listForUser(frame)
        end

        -- Case B: {{Passive|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 passive.
        local label = name or id or "?"
        return string.format(
            "<strong>Unknown passive:</strong> %s[[Category:Pages with unknown passive|%s]]",
            mw.text.nowiki(label),
            label
        )
    end

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

return p