Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Join the Playtest on Steam Now: SpiritVale

Module:GamePassives: Difference between revisions

From SpiritVale Wiki
Created page with "-- 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 = {} ---..."
 
No edit summary
 
(14 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:GamePassives
-- Module:GamePassives
--
--
-- Renders passive skill data (from Data:passives.json) into an infobox-style table.
-- Renders passive skill data (from Data:passives.json) into an
-- Data is loaded via Module:GameData.
-- infobox-style table and can also list all passives for a given user/class.
--
--
-- Supported usage patterns (via Template:Passive):
-- Usage (single passive):
--  {{Passive|Honed Blade}}             -> uses display Name (recommended)
--  {{Passive|Honed Blade}}
--  {{Passive|name=Honed Blade}}         -> explicit Name
--  {{Passive|name=Honed Blade}}
--  {{Passive|id=CritMastery}}           -> Internal Name (power use)
--  {{Passive|id=CritMastery}}
--
-- Usage (auto-list on class page, e.g. "Acolyte"):
--  {{Passive}}                -> lists all Acolyte passives (page name)
--  {{Passive|Acolyte}}        -> same, if no passive literally called "Acolyte"


local GameData = require("Module:GameData")
local GameData = require("Module:GameData")
Line 20: Line 24:


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


local function getArgs(frame)
local function getArgs(frame)
    local parent = frame:getParent()
local parent = frame:getParent()
    if parent then
if parent then
        return parent.args
return parent.args
    end
end
    return frame.args
return frame.args
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


-- Tag body rows so we can style/center without touching the hero rows
local function addRow(tbl, label, value)
local function addRow(tbl, label, value)
    if value == nil or value == "" then
if value == nil or value == "" then
        return
return
    end
end
    local row = tbl:tag("tr")
 
    row:tag("th"):wikitext(label):done()
local row = tbl:tag("tr")
    row:tag("td"):wikitext(value):done()
row:addClass("spiritvale-passive-body-row")
row:tag("th"):wikitext(label):done()
row:tag("td"):wikitext(value):done()
end
end


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


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


Line 78: Line 85:
----------------------------------------------------------------------
----------------------------------------------------------------------


local function formatPassiveEffects(list)
local function asUl(items)
    if type(list) ~= "table" or #list == 0 then
if type(items) ~= "table" or #items == 0 then
        return nil
return nil
    end
end
return '<ul class="spiritvale-infobox-list"><li>'
.. table.concat(items, "</li><li>")
.. "</li></ul>"
end


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


    for _, eff in ipairs(list) do
local parts = {}
        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
if block.Base ~= nil then
            local base = value.Base
table.insert(parts, string.format("Base %s", tostring(block.Base)))
            local per  = value["Per Level"]
end
if block["Per Level"] ~= nil then
table.insert(parts, string.format("%s / Lv", tostring(block["Per Level"])))
end


            local seg = typeName
if #parts == 0 then
return nil
end


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


            local detail = {}
-- Passive Effects: return rows (label/value), not a single text blob
            if base then
local function passiveEffectRows(list)
                table.insert(detail, string.format("Base %.2f", base))
if type(list) ~= "table" or #list == 0 then
            end
return {}
            if per then
end
                table.insert(detail, string.format("%.2f / Lv", per))
            end


            if #detail > 0 then
local rows = {}
                seg = seg .. " – " .. table.concat(detail, ", ")
            end


            table.insert(parts, seg)
for _, eff in ipairs(list) do
        end
if type(eff) == "table" then
    end
local t = eff.Type or {}
local name = t.Name or eff.ID or "Unknown"


    if #parts == 0 then
local value = eff.Value or {}
        return nil
local detail = {}
    end


    -- One per line
if value.Base ~= nil then
    return table.concat(parts, "<br />")
table.insert(detail, string.format("Base %s", tostring(value.Base)))
end
if value["Per Level"] ~= nil then
table.insert(detail, string.format("%s / Lv", tostring(value["Per Level"])))
end
if value.Expression ~= nil and value.Expression ~= "" then
table.insert(detail, tostring(value.Expression))
end
 
-- Optional qualifiers (weapon/stance/etc.), if present in data
local qual =
eff.Weapon or eff["Weapon"] or eff["Weapon Type"] or
eff.Stance or eff["Stance"] or eff["Stance Type"]
 
if type(qual) == "string" and qual ~= "" then
table.insert(detail, qual)
end
 
local right = (#detail > 0) and table.concat(detail, ", ") or "—"
table.insert(rows, { label = name, value = right })
end
end
 
return rows
end
end


local function formatStatusApplications(list)
local function formatStatusApplications(list)
    if type(list) ~= "table" or #list == 0 then
if type(list) ~= "table" or #list == 0 then
        return nil
return nil
    end
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 = {}
 
if type(s.Duration) == "table" then
local t = formatBasePer(s.Duration)
if t then
table.insert(detail, "Duration " .. t)
end
end
 
if type(s.Chance) == "table" then
local t = formatBasePer(s.Chance)
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
 
return asUl(parts)
end


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


            local detailParts = {}
for _, r in ipairs(list) do
            if dur then
if type(r) == "table" then
                table.insert(detailParts, string.format("Dur %.2f", dur))
local names = r["Status Name"]
            end
local label
            if chance then
                table.insert(detailParts, string.format("Chance %.2f", chance))
            end


            if #detailParts > 0 then
if type(names) == "table" then
                seg = seg .. " (" .. table.concat(detailParts, ", ") .. ")"
label = table.concat(names, ", ")
            end
elseif type(names) == "string" then
label = names
else
label = "Status"
end


            table.insert(parts, seg)
local bp = formatBasePer(r)
        end
local seg = label
    end


    if #parts == 0 then
if bp then
        return nil
seg = seg .. " – " .. bp
    end
end


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


local function formatNotes(notes)
local function formatEvents(list)
    if type(notes) ~= "table" or #notes == 0 then
if type(list) ~= "table" or #list == 0 then
        return nil
return nil
    end
end
    return table.concat(notes, "<br />")
 
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"
table.insert(parts, string.format("%s → %s", action, name))
end
end
 
return asUl(parts)
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"])
 
return asUl(parts)
end
 
----------------------------------------------------------------------
-- User matching (for auto lists on class pages)
----------------------------------------------------------------------
 
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
 
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 177: Line 328:


local function buildInfobox(rec)
local function buildInfobox(rec)
    local root = mw.html.create("table")
local root = mw.html.create("table")
    root:addClass("wikitable spiritvale-passive-infobox")
root:addClass("spiritvale-passive-infobox")
 
-- ==========================================================
-- Top "hero" rows: title row (icon + name), then description row
-- ==========================================================
local icon  = rec.Icon
local title = rec.Name or rec["Internal Name"] or "Unknown Passive"
local desc  = rec.Description or ""


    -- Header: icon + name
-- Row 1: centered icon + title (single cell)
    local icon  = rec.Icon
local titleRow = root:tag("tr")
    local title = rec.Name or rec["Internal Name"] or "Unknown Passive"
titleRow:addClass("spiritvale-infobox-main")
titleRow:addClass("sv-hero-title-row")


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


    local titleText = ""
local titleInner = titleCell:tag("div")
    if icon and icon ~= "" then
titleInner:addClass("spiritvale-infobox-main-left-inner")
        titleText = string.format("[[File:%s|64px|link=]] ", icon)
    end
    titleText = titleText + title
    headerCell:wikitext(titleText)


    ------------------------------------------------------------------
if icon and icon ~= "" then
    -- Basic info
titleInner:wikitext(string.format("[[File:%s|80px|link=]]", icon))
    ------------------------------------------------------------------
end
    addRow(root, "Description", rec.Description)
    addRow(root, "Max level", rec["Max Level"] and tostring(rec["Max Level"]))


    ------------------------------------------------------------------
titleInner:tag("div")
    -- Users
:addClass("spiritvale-infobox-title")
    ------------------------------------------------------------------
:wikitext(title)
    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))


    ------------------------------------------------------------------
-- Row 2: description (single cell)
    -- Requirements
if desc ~= "" then
    ------------------------------------------------------------------
local descRow = root:tag("tr")
    local req = rec.Requirements or {}
descRow:addClass("spiritvale-infobox-main")
descRow:addClass("sv-hero-desc-row")


    if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
local descCell = descRow:tag("td")
        local skillParts = {}
descCell:attr("colspan", 2)
        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"]))
local descInner = descCell:tag("div")
    addRow(root, "Required stances", listToText(req["Required Stances"]))
descInner:addClass("spiritvale-infobox-main-right-inner")


    ------------------------------------------------------------------
-- Use <i> to avoid apostrophes breaking ''...'' formatting
    -- Passive effects
local d = descInner:tag("div")
    ------------------------------------------------------------------
d:addClass("spiritvale-infobox-description")
    local effectsText = formatPassiveEffects(rec["Passive Effects"])
d:tag("i"):wikitext(desc)
    addRow(root, "Passive effects", effectsText)
end


    ------------------------------------------------------------------
------------------------------------------------------------------
    -- Status interactions
-- General
    ------------------------------------------------------------------
------------------------------------------------------------------
    local statusApps = formatStatusApplications(rec["Status Applications"])
addRow(root, "Max Level", rec["Max Level"] and tostring(rec["Max Level"]))
    addRow(root, "Status applications", statusApps)


    ------------------------------------------------------------------
-- Classes intentionally removed (template is used on class pages)
    -- Notes
local users = rec.Users or {}
    ------------------------------------------------------------------
addRow(root, "Summons",  listToText(users.Summons))
    local notesText = formatNotes(rec.Notes)
addRow(root, "Monsters", listToText(users.Monsters))
    addRow(root, "Notes", notesText)
addRow(root, "Events",   listToText(users.Events))


    return tostring(root)
------------------------------------------------------------------
-- 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
 
if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
local skillParts = {}
for _, rs in ipairs(req["Required Skills"]) do
local sname = rs["Skill Name"] or rs["Skill ID"] or "Unknown"
local rlv  = rs["Required Level"]
if rlv then
table.insert(skillParts, string.format("%s (Lv.%s)", sname, rlv))
else
table.insert(skillParts, sname)
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
 
------------------------------------------------------------------
-- Passive Effects (one row per effect)
------------------------------------------------------------------
local peRows = passiveEffectRows(rec["Passive Effects"])
for _, r in ipairs(peRows) do
addRow(root, r.label, r.value)
end
 
------------------------------------------------------------------
-- Status Effects
------------------------------------------------------------------
local statusApps = formatStatusApplications(rec["Status Applications"])
local statusRem  = formatStatusRemoval(rec["Status Removal"])
if statusApps or statusRem then
addRow(root, "Applies", statusApps)
addRow(root, "Removes", statusRem)
end
 
------------------------------------------------------------------
-- Modifiers
------------------------------------------------------------------
local modsText = formatModifiers(rec.Modifiers)
if modsText then
addRow(root, "Flags", modsText)
end
 
------------------------------------------------------------------
-- Events
------------------------------------------------------------------
local eventsText = formatEvents(rec.Events)
if eventsText then
addRow(root, "Triggers", eventsText)
end
 
------------------------------------------------------------------
-- Notes
------------------------------------------------------------------
if type(rec.Notes) == "table" and #rec.Notes > 0 then
addRow(root, "Notes", asUl(rec.Notes) or table.concat(rec.Notes, "<br />"))
end
 
return tostring(root)
end
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Public entry point
-- Public: list all passives 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 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: single-passive or auto-list dispatcher
----------------------------------------------------------------------
----------------------------------------------------------------------


function p.infobox(frame)
function p.infobox(frame)
    local args = getArgs(frame)
local args = getArgs(frame)
 
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 = findPassiveByName(name)
end
 
-- 2) Fallback: internal ID
if not rec and id and id ~= "" then
rec = getPassiveById(id)
end


    -- Allow:
-- 3) If still nothing, decide if this is "list mode" or truly unknown.
    --  {{Passive|Honed Blade}}          -> args[1] = "Honed Blade" (Name)
if not rec then
    --  {{Passive|name=Honed Blade}}      -> args.name
local pageTitle = mw.title.getCurrentTitle()
    --  {{Passive|id=CritMastery}}        -> args.id (Internal Name)
local pageName  = pageTitle and pageTitle.text or ""
    local raw1 = args[1]
    local name = args.name or raw1
    local id  = args.id


    local rec
local noExplicitArgs =
(not raw1 or raw1 == "") and
(not args.name or args.name == "") and
(not id or id == "")


    -- 1) Prefer display Name (what editors actually know)
if noExplicitArgs then
    if name and name ~= "" then
return p.listForUser(frame)
        rec = findPassiveByName(name)
end
    end


    -- 2) Fallback: Internal Name if explicitly given
if name and name ~= "" and name == pageName and (not id or id == "") then
    if not rec and id and id ~= "" then
return p.listForUser(frame)
        rec = getPassiveById(id)
end
    end


    if not rec then
local label = name or id or "?"
        local label = name or id or "?"
return string.format(
        return string.format(
"<strong>Unknown passive:</strong> %s[[Category:Pages with unknown passive|%s]]",
            "<strong>Unknown passive:</strong> %s[[Category:Pages with unknown passive|%s]]",
mw.text.nowiki(label),
            mw.text.nowiki(label),
label
            label
)
        )
end
    end


    return buildInfobox(rec)
return buildInfobox(rec)
end
end


return p
return p

Latest revision as of 02:18, 16 December 2025

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
A life of combat has perfected your edge, each critical strike hits with ruthless efficiency.
Max Level10
Critical Damage3 / Lv

or, explicitly:

Honed Blade
A life of combat has perfected your edge, each critical strike hits with ruthless efficiency.
Max Level10
Critical Damage3 / Lv

Internal IDs can still be used when needed:

Honed Blade
A life of combat has perfected your edge, each critical strike hits with ruthless efficiency.
Max Level10
Critical Damage3 / 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 and can also list all passives for a given user/class.
--
-- Usage (single passive):
--   {{Passive|Honed Blade}}
--   {{Passive|name=Honed Blade}}
--   {{Passive|id=CritMastery}}
--
-- Usage (auto-list on class page, e.g. "Acolyte"):
--   {{Passive}}                 -> lists all Acolyte passives (page name)
--   {{Passive|Acolyte}}         -> same, if no passive literally called "Acolyte"

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, sep)
	if type(list) ~= "table" or #list == 0 then
		return nil
	end
	return table.concat(list, sep or ", ")
end

-- Tag body rows so we can style/center without touching the hero rows
local function addRow(tbl, label, value)
	if value == nil or value == "" then
		return
	end

	local row = tbl:tag("tr")
	row:addClass("spiritvale-passive-body-row")
	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 asUl(items)
	if type(items) ~= "table" or #items == 0 then
		return nil
	end
	return '<ul class="spiritvale-infobox-list"><li>'
		.. table.concat(items, "</li><li>")
		.. "</li></ul>"
end

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

-- Passive Effects: return rows (label/value), not a single text blob
local function passiveEffectRows(list)
	if type(list) ~= "table" or #list == 0 then
		return {}
	end

	local rows = {}

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

			local value = eff.Value or {}
			local detail = {}

			if value.Base ~= nil then
				table.insert(detail, string.format("Base %s", tostring(value.Base)))
			end
			if value["Per Level"] ~= nil then
				table.insert(detail, string.format("%s / Lv", tostring(value["Per Level"])))
			end
			if value.Expression ~= nil and value.Expression ~= "" then
				table.insert(detail, tostring(value.Expression))
			end

			-- Optional qualifiers (weapon/stance/etc.), if present in data
			local qual =
				eff.Weapon or eff["Weapon"] or eff["Weapon Type"] or
				eff.Stance or eff["Stance"] or eff["Stance Type"]

			if type(qual) == "string" and qual ~= "" then
				table.insert(detail, qual)
			end

			local right = (#detail > 0) and table.concat(detail, ", ") or "—"
			table.insert(rows, { label = name, value = right })
		end
	end

	return rows
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 = {}

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

			if type(s.Chance) == "table" then
				local t = formatBasePer(s.Chance)
				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

	return asUl(parts)
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

	return asUl(parts)
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"
			table.insert(parts, string.format("%s → %s", action, name))
		end
	end

	return asUl(parts)
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"])

	return asUl(parts)
end

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

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

	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("spiritvale-passive-infobox")

	-- ==========================================================
	-- Top "hero" rows: title row (icon + name), then description row
	-- ==========================================================
	local icon  = rec.Icon
	local title = rec.Name or rec["Internal Name"] or "Unknown Passive"
	local desc  = rec.Description or ""

	-- Row 1: centered icon + title (single cell)
	local titleRow = root:tag("tr")
	titleRow:addClass("spiritvale-infobox-main")
	titleRow:addClass("sv-hero-title-row")

	local titleCell = titleRow:tag("th")
	titleCell:attr("colspan", 2)

	local titleInner = titleCell:tag("div")
	titleInner:addClass("spiritvale-infobox-main-left-inner")

	if icon and icon ~= "" then
		titleInner:wikitext(string.format("[[File:%s|80px|link=]]", icon))
	end

	titleInner:tag("div")
		:addClass("spiritvale-infobox-title")
		:wikitext(title)

	-- Row 2: description (single cell)
	if desc ~= "" then
		local descRow = root:tag("tr")
		descRow:addClass("spiritvale-infobox-main")
		descRow:addClass("sv-hero-desc-row")

		local descCell = descRow:tag("td")
		descCell:attr("colspan", 2)

		local descInner = descCell:tag("div")
		descInner:addClass("spiritvale-infobox-main-right-inner")

		-- Use <i> to avoid apostrophes breaking ''...'' formatting
		local d = descInner:tag("div")
		d:addClass("spiritvale-infobox-description")
		d:tag("i"):wikitext(desc)
	end

	------------------------------------------------------------------
	-- General
	------------------------------------------------------------------
	addRow(root, "Max Level", rec["Max Level"] and tostring(rec["Max Level"]))

	-- Classes intentionally removed (template is used on class pages)
	local users = rec.Users or {}
	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

		if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
			local skillParts = {}
			for _, rs in ipairs(req["Required Skills"]) do
				local sname = rs["Skill Name"] or rs["Skill ID"] or "Unknown"
				local rlv   = rs["Required Level"]
				if rlv then
					table.insert(skillParts, string.format("%s (Lv.%s)", sname, rlv))
				else
					table.insert(skillParts, sname)
				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

	------------------------------------------------------------------
	-- Passive Effects (one row per effect)
	------------------------------------------------------------------
	local peRows = passiveEffectRows(rec["Passive Effects"])
	for _, r in ipairs(peRows) do
		addRow(root, r.label, r.value)
	end

	------------------------------------------------------------------
	-- Status Effects
	------------------------------------------------------------------
	local statusApps = formatStatusApplications(rec["Status Applications"])
	local statusRem  = formatStatusRemoval(rec["Status Removal"])
	if statusApps or statusRem then
		addRow(root, "Applies", statusApps)
		addRow(root, "Removes", statusRem)
	end

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

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

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

	return tostring(root)
end

----------------------------------------------------------------------
-- Public: list all passives 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 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: single-passive or auto-list dispatcher
----------------------------------------------------------------------

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

	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 = findPassiveByName(name)
	end

	-- 2) Fallback: internal ID
	if not rec and id and id ~= "" then
		rec = getPassiveById(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 == "")

		if noExplicitArgs then
			return p.listForUser(frame)
		end

		if name and name ~= "" and name == pageName and (not id or id == "") then
			return p.listForUser(frame)
		end

		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

	return buildInfobox(rec)
end

return p