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

Join the Playtest on Steam Now: SpiritVale

Module:GameSkills: Difference between revisions

From SpiritVale Wiki
No edit summary
No edit summary
Line 4: Line 4:
-- and can also list all skills for a given user/class (page name).
-- and can also list all skills for a given user/class (page name).
--
--
-- Features:
-- Standard Hero Layout (reused across the wiki):
-- - Per-skill Level Select slider (client-side JS updates .sv-dyn spans)
--   1) hero-title-bar        (icon + name)
--  - Default level = Max Level
--   2) hero-description-bar (description strip)
-- - .sv-skill-card + data-max-level / data-level hooks for JS
--   3) hero-modules row      (4 slots: hero-module-1..4)
-- - Uses dynamic spans instead of long Lv1/Lv2/... lists
--       - Slot 1: module-level-selector
-- - Unified top band: Level Select (left) + Type/Element/Target/Cast (right)
--       - Slot 2: module-skill-type
-- - List-mode: wraps all skills in one wrapper panel + stable .sv-skill-item divs
--       - Slot 3: empty (reserved)
--       - Slot 4: empty (reserved)
--
--
-- NOTE: We add a small amount of inline styling on the top-band inner panels
-- Requires Common.js logic that updates:
-- (Level Select + Type box) so light mode remains readable even if a skin/theme
--  - .sv-dyn spans via data-series
-- class selector isn’t reliable.
--   - .sv-level-num + data-level on .sv-skill-card
--   - binds to input.sv-level-range inside each card


local GameData = require("Module:GameData")
local GameData = require("Module:GameData")
Line 94: Line 96:
end
end


local function addRow(tbl, label, value)
-- Add a labeled infobox row (with optional hooks for future)
local function addRow(tbl, label, value, rowClass, dataKey)
if value == nil or value == "" then
if value == nil or value == "" then
return
return
end
end
local row = tbl:tag("tr")
local row = tbl:tag("tr")
row:addClass("sv-row")
if rowClass then
row:addClass(rowClass)
end
if dataKey then
row:attr("data-field", dataKey)
end
row:tag("th"):wikitext(label):done()
row:tag("th"):wikitext(label):done()
row:tag("td"):wikitext(value):done()
row:tag("td"):wikitext(value):done()
Line 234: Line 246:
end
end


-- Raw text for a Base/Per Level block (used by Special Mechanics & Source fallbacks)
local function valuePairRawText(block)
local function valuePairRawText(block)
if type(block) ~= "table" then
if type(block) ~= "table" then
Line 296: Line 307:
for _, rec in ipairs(dataset.records or {}) do
for _, rec in ipairs(dataset.records or {}) do
if type(rec) == "table" then
if type(rec) == "table" then
if rec["External Name"] == name or rec["Name"] == name or rec["Display Name"] == name then
if rec["External Name"] == name or rec.Name == name or rec["Display Name"] == name then
return rec
return rec
end
end
Line 328: Line 339:
end
end


-- Build a value-only output for a Base/Per Level block, using dyn spans when needed
local function valuePairDynamicValueOnly(block, maxLevel, level)
local function valuePairDynamicValueOnly(block, maxLevel, level)
if type(block) ~= "table" then
if type(block) ~= "table" then
Line 387: Line 397:
end
end


-- BACKCOMPAT: old damage list formatter (safe to remove once migrated)
-- BACKCOMPAT: old damage list formatter
local function formatDamageEntry(entry, maxLevel, level)
local function formatDamageEntry(entry, maxLevel, level)
if type(entry) ~= "table" then
if type(entry) ~= "table" then
Line 461: Line 471:
end
end


-- Scaling supports either:
-- Scaling supports either a single dict or a list
--  - single dict: {Percent=..., Scaling ID/Name...}
--  - list of dicts: [{...},{...}]
local function formatScaling(scaling, basisOverride)
local function formatScaling(scaling, basisOverride)
if type(scaling) ~= "table" then
if type(scaling) ~= "table" then
Line 832: Line 840:
pageName = mw.ustring.lower(pageName)
pageName = mw.ustring.lower(pageName)


local ext = trim(rec["External Name"] or rec["Name"] or rec["Display Name"])
local ext = trim(rec["External Name"] or rec.Name or rec["Display Name"])
local internal = trim(rec["Internal Name"] or rec["InternalName"] or rec["InternalID"])
local internal = trim(rec["Internal Name"] or rec.InternalName or rec.InternalID)


return (ext and mw.ustring.lower(ext) == pageName) or (internal and mw.ustring.lower(internal) == pageName) or false
return (ext and mw.ustring.lower(ext) == pageName) or (internal and mw.ustring.lower(internal) == pageName) or false
Line 839: Line 847:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Top band UI
-- Hero modules (4-slot scaffold)
----------------------------------------------------------------------
----------------------------------------------------------------------


local function buildLevelSelectUI(level, maxLevel)
local function moduleBox(slot, extraClasses, innerHtml, isEmpty)
local wrap = mw.html.create("div")
local box = mw.html.create("div")
wrap:addClass("sv-level-ui")
box:addClass("hero-module")
box:addClass("hero-module-" .. tostring(slot))
box:attr("data-hero-module", tostring(slot))
 
if extraClasses then
if type(extraClasses) == "string" then
box:addClass(extraClasses)
elseif type(extraClasses) == "table" then
for _, c in ipairs(extraClasses) do
box:addClass(c)
end
end
end


-- Inline “light-mode-safe” panel styling (works in both modes)
if isEmpty then
wrap:css("background", "rgba(90, 78, 124, 0.18)")
box:addClass("hero-module-empty")
wrap:css("border", "1px solid rgba(55, 43, 84, 0.22)")
end
wrap:css("border-radius", "10px")


wrap:tag("div")
local body = box:tag("div"):addClass("hero-module-body")
if innerHtml and innerHtml ~= "" then
body:wikitext(innerHtml)
end
 
return tostring(box)
end
 
local function buildModuleLevelSelector(level, maxLevel)
local inner = mw.html.create("div")
inner:addClass("sv-level-ui")
 
inner:tag("div")
:addClass("sv-level-title")
:addClass("sv-level-title")
:wikitext("Level Select")
:wikitext("Level Select")


wrap:tag("div")
inner:tag("div")
:addClass("sv-level-label")
:addClass("sv-level-label")
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))


wrap:tag("div"):addClass("sv-level-slider")
local slider = inner:tag("div"):addClass("sv-level-slider")
slider:tag("input")
:attr("type", "range")
:attr("min", "1")
:attr("max", tostring(maxLevel))
:attr("value", tostring(level))
:addClass("sv-level-range")
:attr("aria-label", "Skill level select")


return tostring(wrap)
return moduleBox(1, "module-level-selector", tostring(inner), false)
end
end


local function buildTypeTableUI(typeBlock)
local function buildModuleSkillType(typeBlock)
if type(typeBlock) ~= "table" or next(typeBlock) == nil then
typeBlock = (type(typeBlock) == "table") and typeBlock or {}
return nil
end
 
local wrap = mw.html.create("div")
wrap:addClass("sv-type-grid")
 
-- Inline “light-mode-safe” panel styling (works in both modes)
wrap:css("background", "rgba(90, 78, 124, 0.18)")
wrap:css("border", "1px solid rgba(55, 43, 84, 0.22)")
wrap:css("border-radius", "10px")
 
local added = false


local function valName(x)
local function valName(x)
Line 893: Line 919:
end
end


local grid = mw.html.create("div")
grid:addClass("sv-type-grid")
local added = false
local function addChunk(label, rawVal)
local function addChunk(label, rawVal)
local v = valName(rawVal)
local v = valName(rawVal)
Line 900: Line 930:
added = true
added = true


local chunk = wrap:tag("div"):addClass("sv-type-chunk")
local chunk = grid:tag("div"):addClass("sv-type-chunk")
chunk:tag("div"):addClass("sv-type-label"):wikitext(mw.text.nowiki(label))
chunk:tag("div"):addClass("sv-type-label"):wikitext(mw.text.nowiki(label))
chunk:tag("div"):addClass("sv-type-value"):wikitext(mw.text.nowiki(v))
chunk:tag("div"):addClass("sv-type-value"):wikitext(mw.text.nowiki(v))
end
end


-- NEW keys (with fallback to old keys)
addChunk("Damage",  typeBlock.Damage  or typeBlock["Damage Type"])
addChunk("Damage",  typeBlock.Damage  or typeBlock["Damage Type"])
addChunk("Element", typeBlock.Element or typeBlock["Element Type"])
addChunk("Element", typeBlock.Element or typeBlock["Element Type"])
Line 911: Line 940:
addChunk("Cast",    typeBlock.Cast    or typeBlock["Cast Type"])
addChunk("Cast",    typeBlock.Cast    or typeBlock["Cast Type"])


if not added then
local body = added and tostring(grid) or ""
return nil
return moduleBox(2, "module-skill-type", body, false)
end
end
 
local function buildEmptyModule(slot)
return moduleBox(slot, nil, "", true)
end
 
local function buildHeroModulesUI(rec, level, maxLevel)
local grid = mw.html.create("div")
grid:addClass("hero-modules-grid")
 
grid:wikitext(buildModuleLevelSelector(level, maxLevel))
grid:wikitext(buildModuleSkillType(rec.Type or {}))
grid:wikitext(buildEmptyModule(3))
grid:wikitext(buildEmptyModule(4))


return tostring(wrap)
return tostring(grid)
end
end


local function addTopBand(tbl, levelUI, typeUI)
local function addHeroModulesRow(tbl, modulesUI)
if not levelUI and not typeUI then
if not modulesUI or modulesUI == "" then
return
return
end
end


local row = tbl:tag("tr")
local row = tbl:tag("tr")
row:addClass("hero-modules-row")
local cell = row:tag("td")
local cell = row:tag("td")
cell:attr("colspan", 2)
cell:attr("colspan", 2)
cell:addClass("sv-topband-cell")
cell:addClass("hero-modules-cell")
 
cell:wikitext(modulesUI)
-- Keep nested table (your CSS targets it)
local inner = cell:tag("table")
inner:addClass("sv-topband-table")
 
local tr = inner:tag("tr")
 
if levelUI and typeUI then
tr:tag("td"):wikitext(levelUI):done()
tr:tag("td"):wikitext(typeUI):done()
elseif levelUI then
tr:tag("td"):attr("colspan", 2):wikitext(levelUI):done()
else
tr:tag("td"):attr("colspan", 2):wikitext(typeUI):done()
end
end
end


Line 966: Line 996:
end
end


-- Hero rows
-- Useful future hooks (safe, non-breaking)
local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
if internalId then
root:attr("data-skill-id", internalId)
end
 
-- Hero: title bar
local icon  = rec.Icon
local icon  = rec.Icon
local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"
local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"
Line 974: Line 1,010:
heroRow:addClass("spiritvale-infobox-main")
heroRow:addClass("spiritvale-infobox-main")
heroRow:addClass("sv-hero-title-row")
heroRow:addClass("sv-hero-title-row")
heroRow:addClass("hero-title-bar")


local heroCell = heroRow:tag("th")
local heroCell = heroRow:tag("th")
Line 990: Line 1,027:
:wikitext(title)
:wikitext(title)


-- Hero: description bar
if desc ~= "" then
if desc ~= "" then
local descRow = root:tag("tr")
local descRow = root:tag("tr")
descRow:addClass("spiritvale-infobox-main")
descRow:addClass("spiritvale-infobox-main")
descRow:addClass("sv-hero-desc-row")
descRow:addClass("sv-hero-desc-row")
descRow:addClass("hero-description-bar")


local descCell = descRow:tag("td")
local descCell = descRow:tag("td")
Line 1,007: Line 1,046:
end
end


-- Unified top band
-- Hero modules row (4-slot scaffold)
addTopBand(
addHeroModulesRow(root, buildHeroModulesUI(rec, level, maxLevel))
root,
buildLevelSelectUI(level, maxLevel),
buildTypeTableUI(rec.Type or {})
)


-- Users
-- Users
if showUsers then
if showUsers then
local users = rec.Users or {}
local users = rec.Users or {}
addRow(root, "Classes",  listToText(users.Classes))
addRow(root, "Classes",  listToText(users.Classes),  "sv-row-users", "Users.Classes")
addRow(root, "Summons",  listToText(users.Summons))
addRow(root, "Summons",  listToText(users.Summons),  "sv-row-users", "Users.Summons")
addRow(root, "Monsters", listToText(users.Monsters))
addRow(root, "Monsters", listToText(users.Monsters), "sv-row-users", "Users.Monsters")
addRow(root, "Events",  listToText(users.Events))
addRow(root, "Events",  listToText(users.Events),  "sv-row-users", "Users.Events")
end
end


-- Requirements
-- Requirements
local req = rec.Requirements or {}
local req = rec.Requirements or {}
if (req["Required Skills"] and #req["Required Skills"] > 0)
local hasReq =
or (req["Required Weapons"] and #req["Required Weapons"] > 0)
(type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0) or
or (req["Required Stances"] and #req["Required Stances"] > 0) then
(type(req["Required Weapons"]) == "table" and #req["Required Weapons"] > 0) or
(type(req["Required Stances"]) == "table" and #req["Required Stances"] > 0)


if hasReq then
if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
local skillParts = {}
local skillParts = {}
Line 1,040: Line 1,077:
end
end
end
end
addRow(root, "Required Skills", table.concat(skillParts, ", "))
addRow(root, "Required Skills", table.concat(skillParts, ", "), "sv-row-req", "Requirements.Required Skills")
end
end


addRow(root, "Required Weapons", listToText(req["Required Weapons"]))
addRow(root, "Required Weapons", listToText(req["Required Weapons"]), "sv-row-req", "Requirements.Required Weapons")
addRow(root, "Required Stances", listToText(req["Required Stances"]))
addRow(root, "Required Stances", listToText(req["Required Stances"]), "sv-row-req", "Requirements.Required Stances")
end
end


Line 1,050: Line 1,087:
local mech = rec.Mechanics or {}
local mech = rec.Mechanics or {}
if next(mech) ~= nil then
if next(mech) ~= nil then
addRow(root, "Range", formatUnitValue(mech.Range))
addRow(root, "Range", formatUnitValue(mech.Range), "sv-row-mech", "Mechanics.Range")
addRow(root, "Area",  formatArea(mech.Area, maxLevel, level))
addRow(root, "Area",  formatArea(mech.Area, maxLevel, level), "sv-row-mech", "Mechanics.Area")


if mech["Autocast Multiplier"] ~= nil then
if mech["Autocast Multiplier"] ~= nil then
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]))
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
end
end


addRow(root, "Timing",            formatTimingBlock(mech["Basic Timings"], maxLevel, level))
addRow(root, "Timing",            formatTimingBlock(mech["Basic Timings"], maxLevel, level), "sv-row-mech", "Mechanics.Basic Timings")
addRow(root, "Resource Cost",    formatResourceCost(mech["Resource Cost"], maxLevel, level))
addRow(root, "Resource Cost",    formatResourceCost(mech["Resource Cost"], maxLevel, level), "sv-row-mech", "Mechanics.Resource Cost")
addRow(root, "Combo",            formatCombo(mech.Combo))
addRow(root, "Combo",            formatCombo(mech.Combo), "sv-row-mech", "Mechanics.Combo")
addRow(root, "Special Mechanics", formatMechanicEffects(mech.Effects, maxLevel, level))
addRow(root, "Special Mechanics", formatMechanicEffects(mech.Effects, maxLevel, level), "sv-row-mech", "Mechanics.Effects")
end
end


-- Source (new) + Scaling, with backcompat for old Damage
-- Source (new) + Scaling, with backcompat for old Damage
if type(rec.Source) == "table" then
if type(rec.Source) == "table" then
addRow(root, "Source", formatSource(rec.Source, maxLevel, level))
addRow(root, "Source", formatSource(rec.Source, maxLevel, level), "sv-row-source", "Source")


local basisOverride =
local basisOverride =
(rec.Source.Type == "Healing" or rec.Source.Healing == true) and "Healing" or nil
(rec.Source.Type == "Healing" or rec.Source.Healing == true) and "Healing" or nil


addRow(root, "Scaling", formatScaling(rec.Source.Scaling, basisOverride))
addRow(root, "Scaling", formatScaling(rec.Source.Scaling, basisOverride), "sv-row-source", "Source.Scaling")
else
else
local dmg = rec.Damage or {}
local dmg = rec.Damage or {}
Line 1,095: Line 1,132:
local pureHealing = (#healOnly > 0) and (#mainNonHeal == 0) and (not flatHas) and (not reflHas)
local pureHealing = (#healOnly > 0) and (#mainNonHeal == 0) and (not flatHas) and (not reflHas)


addRow(root, "Main Damage",    formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)))
addRow(root, "Main Damage",    formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main Damage")
addRow(root, "Flat Damage",    formatDamageList(flatList, maxLevel, level, false))
addRow(root, "Flat Damage",    formatDamageList(flatList, maxLevel, level, false), "sv-row-source", "Damage.Flat Damage")
addRow(root, "Reflect Damage", formatDamageList(reflList, maxLevel, level, false))
addRow(root, "Reflect Damage", formatDamageList(reflList, maxLevel, level, false), "sv-row-source", "Damage.Reflect Damage")
addRow(root, "Healing",        formatDamageList(healOnly, maxLevel, level, false))
addRow(root, "Healing",        formatDamageList(healOnly, maxLevel, level, false), "sv-row-source", "Damage.Healing")


addRow(root, "Scaling", formatScaling(dmg.Scaling, pureHealing and "Healing" or nil))
addRow(root, "Scaling", formatScaling(dmg.Scaling, pureHealing and "Healing" or nil), "sv-row-source", "Damage.Scaling")
end
end
end
end
Line 1,107: Line 1,144:
local modsText = formatModifiers(rec.Modifiers)
local modsText = formatModifiers(rec.Modifiers)
if modsText then
if modsText then
addRow(root, "Flags", modsText)
addRow(root, "Flags", modsText, "sv-row-meta", "Modifiers")
end
end


Line 1,114: Line 1,151:
local statusRem  = formatStatusRemoval(rec["Status Removal"], maxLevel, level)
local statusRem  = formatStatusRemoval(rec["Status Removal"], maxLevel, level)
if statusApps or statusRem then
if statusApps or statusRem then
addRow(root, "Applies", statusApps)
addRow(root, "Applies", statusApps, "sv-row-status", "Status Applications")
addRow(root, "Removes", statusRem)
addRow(root, "Removes", statusRem,  "sv-row-status", "Status Removal")
end
end


Line 1,121: Line 1,158:
local eventsText = formatEvents(rec.Events)
local eventsText = formatEvents(rec.Events)
if eventsText then
if eventsText then
addRow(root, "Triggers", eventsText)
addRow(root, "Triggers", eventsText, "sv-row-meta", "Events")
end
end


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


Line 1,158: Line 1,195:


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



Revision as of 15:24, 16 December 2025

Module:GameSkills

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

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

This module:

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

Data source

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

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

Each record is a single skill. Important keys:

  • "Name" – the display name (what players and editors will usually see and use).
  • "Internal Name" – the stable ID used internally and available as an optional parameter for power users and tooling.

Output

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

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

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

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


Public interface

The module exposes a single entry point for templates:

GameSkills.infobox(frame)

This is usually called via #invoke from a template, not directly from pages.

It accepts the following parameters (either passed directly or via a wrapper template):

  • 1 – unnamed parameter; treated as the skill "Name".
  • name – explicit display "Name" of the skill (equivalent to 1).
  • id"Internal Name" of the skill (optional fallback / power use).

Lookup order:

  1. If name or the first unnamed parameter is provided and matches a record’s "Name", that record is used.
  2. Otherwise, if id is provided and matches an "Internal Name", that record is used.
  3. If nothing is found, a small error message is returned and the page is categorized for tracking.

Example direct usage (not recommended; normally use a template):

No skills found for: GameSkills

or:

No skills found for: GameSkills

Template:Skill

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

Template:Skill
 No skills found for: GameSkills

Typical usage on any page:

Bash
Delivers a crushing blow with a chance to stun the target.
Level Select
Level 10 / 10
<input type="range" min="1" max="10" value="10" class="sv-level-range" aria-label="Skill level select" />
Damage
Melee
Element
Neutral
Target
Enemy
Cast
Target
ClassesWarrior, Weaver
MonstersAshrend, Goblin King
Required SkillsAxe Mastery (Lv.1)
Range0
AreaDistance: 0
Size: None
TimingCast Time: 0s
Cooldown: 0s
Duration: 0s
Resource CostMP: 12
HP: 0
ComboType: Ready, Duration: 4s
Special MechanicsBackslide: 0
Chains: 0
Clone: 0
Hit Limit: 0
Hits: 0
Instances: 0
Knockback: 0
Leech: 0%
Leech MP: 0%
Max Instances: 0
Mount: 0
Pull: 0
Revive: 0%
Spell Copy: 0
Summon: 0
Wall: 0
SourceDamage: 325% Attack
Scaling2% Damage Per Strength
FlagsSpecial: Self Centered
AppliesTarget – Stun (Duration: 3s, Chance: 30%)

or, explicitly:

Bash
Delivers a crushing blow with a chance to stun the target.
Level Select
Level 10 / 10
<input type="range" min="1" max="10" value="10" class="sv-level-range" aria-label="Skill level select" />
Damage
Melee
Element
Neutral
Target
Enemy
Cast
Target
ClassesWarrior, Weaver
MonstersAshrend, Goblin King
Required SkillsAxe Mastery (Lv.1)
Range0
AreaDistance: 0
Size: None
TimingCast Time: 0s
Cooldown: 0s
Duration: 0s
Resource CostMP: 12
HP: 0
ComboType: Ready, Duration: 4s
Special MechanicsBackslide: 0
Chains: 0
Clone: 0
Hit Limit: 0
Hits: 0
Instances: 0
Knockback: 0
Leech: 0%
Leech MP: 0%
Max Instances: 0
Mount: 0
Pull: 0
Revive: 0%
Spell Copy: 0
Summon: 0
Wall: 0
SourceDamage: 325% Attack
Scaling2% Damage Per Strength
FlagsSpecial: Self Centered
AppliesTarget – Stun (Duration: 3s, Chance: 30%)

Internal IDs can still be used when needed:

Unknown Skill: Bash_InternalId

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


-- Module:GameSkills
--
-- Renders active skill data (Data:skills.json) into an infobox-style table,
-- and can also list all skills for a given user/class (page name).
--
-- Standard Hero Layout (reused across the wiki):
--   1) hero-title-bar        (icon + name)
--   2) hero-description-bar  (description strip)
--   3) hero-modules row      (4 slots: hero-module-1..4)
--        - Slot 1: module-level-selector
--        - Slot 2: module-skill-type
--        - Slot 3: empty (reserved)
--        - Slot 4: empty (reserved)
--
-- Requires Common.js logic that updates:
--   - .sv-dyn spans via data-series
--   - .sv-level-num + data-level on .sv-skill-card
--   - binds to input.sv-level-range inside each card

local GameData = require("Module:GameData")

local p = {}

----------------------------------------------------------------------
-- Data cache
----------------------------------------------------------------------

local skillsCache

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

----------------------------------------------------------------------
-- Small utilities
----------------------------------------------------------------------

local function getArgs(frame)
	local parent = frame:getParent()
	return (parent and parent.args) or frame.args
end

local function trim(s)
	if type(s) ~= "string" then
		return nil
	end
	s = mw.text.trim(s)
	return (s ~= "" and s) or nil
end

local function toNum(v)
	if type(v) == "number" then
		return v
	end
	if type(v) == "string" then
		return tonumber(v)
	end
	if type(v) == "table" and v.Value ~= nil then
		return toNum(v.Value)
	end
	return nil
end

local function clamp(n, lo, hi)
	if type(n) ~= "number" then
		return lo
	end
	if n < lo then return lo end
	if n > hi then return hi end
	return n
end

local function fmtNum(n)
	if type(n) ~= "number" then
		return (n ~= nil) and tostring(n) or nil
	end

	if math.abs(n - math.floor(n)) < 1e-9 then
		return tostring(math.floor(n))
	end

	local s = string.format("%.4f", n)
	s = mw.ustring.gsub(s, "0+$", "")
	s = mw.ustring.gsub(s, "%.$", "")
	return s
end

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

-- Add a labeled infobox row (with optional hooks for future)
local function addRow(tbl, label, value, rowClass, dataKey)
	if value == nil or value == "" then
		return
	end

	local row = tbl:tag("tr")
	row:addClass("sv-row")
	if rowClass then
		row:addClass(rowClass)
	end
	if dataKey then
		row:attr("data-field", dataKey)
	end

	row:tag("th"):wikitext(label):done()
	row:tag("td"):wikitext(value):done()
end

-- Handles either a scalar OR { Value = ..., Unit = ... }
-- IMPORTANT: wikiprep often converts unit-wrapped pairs into strings already.
local function formatUnitValue(v)
	if type(v) == "table" and v.Value ~= nil then
		local unit = v.Unit
		local val  = v.Value

		if unit == "percent_decimal" or unit == "percent_whole" or unit == "percent" then
			return tostring(val) .. "%"
		elseif unit == "seconds" then
			return tostring(val) .. "s"
		elseif unit == "meters" then
			return tostring(val) .. "m"
		elseif unit == "tiles" then
			return tostring(val) .. " tiles"
		elseif unit and unit ~= "" then
			return tostring(val) .. " " .. tostring(unit)
		else
			return tostring(val)
		end
	end

	return (v ~= nil) and tostring(v) or nil
end

----------------------------------------------------------------------
-- Dynamic spans (JS-driven)
----------------------------------------------------------------------

local function dynSpan(series, level)
	if type(series) ~= "table" or #series == 0 then
		return nil
	end

	level = clamp(level or #series, 1, #series)

	local span = mw.html.create("span")
	span:addClass("sv-dyn")
	span:attr("data-series", mw.text.jsonEncode(series))
	span:wikitext(mw.text.nowiki(series[level] or ""))

	return tostring(span)
end

local function isFlatList(list)
	if type(list) ~= "table" or #list == 0 then
		return false
	end
	local first = tostring(list[1])
	for i = 2, #list do
		if tostring(list[i]) ~= first then
			return false
		end
	end
	return true
end

local function isNonZeroScalar(v)
	if v == nil then
		return false
	end
	if type(v) == "number" then
		return v ~= 0
	end
	if type(v) == "string" then
		local n = tonumber(v)
		if n == nil then
			return v ~= ""
		end
		return n ~= 0
	end
	if type(v) == "table" and v.Value ~= nil then
		return isNonZeroScalar(v.Value)
	end
	return true
end

-- Base/Per Level renderer:
-- - Per Level list -> dynamic span (or single value if flat)
-- - Per Level scalar -> "Base" + "Per Level" lines
local function valuePairDynamicLines(name, block, maxLevel, level)
	if type(block) ~= "table" then
		return {}
	end

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

	-- Per Level list (expanded by wikiprep)
	if type(per) == "table" then
		if #per == 0 then
			local baseText = formatUnitValue(base)
			return baseText and { string.format("%s: %s", name, mw.text.nowiki(baseText)) } or {}
		end

		if isFlatList(per) then
			local baseText = formatUnitValue(base)
			local one = formatUnitValue(per[1]) or tostring(per[1])
			local show = baseText or one
			return show and { string.format("%s: %s", name, mw.text.nowiki(show)) } or {}
		end

		local series = {}
		for _, v in ipairs(per) do
			table.insert(series, formatUnitValue(v) or tostring(v))
		end

		local dyn = dynSpan(series, level)
		return dyn and { string.format("%s: %s", name, dyn) } or {}
	end

	-- scalar Per Level
	local lines = {}
	local baseText = formatUnitValue(base)
	local perText  = formatUnitValue(per)

	if baseText then
		table.insert(lines, string.format("%s: %s", name, mw.text.nowiki(baseText)))
	end
	if perText and isNonZeroScalar(per) then
		table.insert(lines, string.format("%s Per Level: %s", name, mw.text.nowiki(perText)))
	end

	return lines
end

local function valuePairDynamicText(name, block, maxLevel, level, sep)
	local lines = valuePairDynamicLines(name, block, maxLevel, level)
	return (#lines > 0) and table.concat(lines, sep or "<br />") or nil
end

local function valuePairRawText(block)
	if type(block) ~= "table" then
		return nil
	end

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

	if type(per) == "table" then
		if #per == 0 then
			return formatUnitValue(base)
		end
		if isFlatList(per) then
			return formatUnitValue(base) or tostring(per[1])
		end

		local vals = {}
		for _, v in ipairs(per) do
			table.insert(vals, formatUnitValue(v) or tostring(v))
		end
		return (#vals > 0) and table.concat(vals, " / ") or nil
	end

	local baseText = formatUnitValue(base)
	local perText  = formatUnitValue(per)

	if baseText and perText and isNonZeroScalar(per) then
		return string.format("%s (Per Level: %s)", baseText, perText)
	end

	return baseText or perText
end

----------------------------------------------------------------------
-- Lookups
----------------------------------------------------------------------

local function getSkillById(id)
	id = trim(id)
	if not id then
		return nil
	end
	local dataset = getSkills()
	return (dataset.byId or {})[id]
end

local function findSkillByName(name)
	name = trim(name)
	if not name then
		return nil
	end

	local dataset = getSkills()
	local byName = dataset.byName or {}

	if byName[name] then
		return byName[name]
	end

	for _, rec in ipairs(dataset.records or {}) do
		if type(rec) == "table" then
			if rec["External Name"] == name or rec.Name == name or rec["Display Name"] == name then
				return rec
			end
		end
	end

	return nil
end

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

local function basisLabel(entry, isHealing)
	if isHealing then
		return "Healing"
	end

	local atk  = entry and entry["ATK-Based"]
	local matk = entry and entry["MATK-Based"]

	if atk and matk then
		return "Attack/Magic Attack"
	elseif atk then
		return "Attack"
	elseif matk then
		return "Magic Attack"
	end

	return "Damage"
end

local function valuePairDynamicValueOnly(block, maxLevel, level)
	if type(block) ~= "table" then
		return nil
	end

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

	if type(per) == "table" then
		if #per == 0 then
			local baseText = formatUnitValue(base)
			return baseText and mw.text.nowiki(baseText) or nil
		end

		if isFlatList(per) then
			local one  = formatUnitValue(per[1]) or tostring(per[1])
			local show = formatUnitValue(base) or one
			return show and mw.text.nowiki(show) or nil
		end

		local series = {}
		for _, v in ipairs(per) do
			table.insert(series, formatUnitValue(v) or tostring(v))
		end
		return dynSpan(series, level)
	end

	local txt = valuePairRawText(block)
	return txt and mw.text.nowiki(txt) or nil
end

-- Source (new unified Damage/Flat/Reflect/Healing) formatter
local function formatSource(src, maxLevel, level)
	if type(src) ~= "table" then
		return nil
	end

	local kind = src.Type or "Damage"
	local isHealing = (kind == "Healing") or (src.Healing == true)

	local basis = basisLabel(src, isHealing)
	if basis and mw.ustring.lower(tostring(basis)) == mw.ustring.lower(tostring(kind)) then
		basis = nil
	end

	local val = valuePairDynamicValueOnly(src, maxLevel, level)
	if not val then
		return nil
	end

	local out = mw.text.nowiki(tostring(kind)) .. ": " .. val
	if basis then
		out = out .. " " .. mw.text.nowiki(tostring(basis))
	end

	return out
end

-- BACKCOMPAT: old damage list formatter
local function formatDamageEntry(entry, maxLevel, level)
	if type(entry) ~= "table" then
		return nil
	end

	local isHealing = (entry.Type == "Healing")
	local basis = isHealing and "Healing" or basisLabel(entry, false)

	local baseRaw = entry["Base %"]
	local perRaw  = entry["Per Level %"]

	local baseN = toNum(baseRaw)
	local perN  = toNum(perRaw)

	local function baseIsPresent()
		if baseN ~= nil then
			return baseN ~= 0
		end
		if baseRaw ~= nil then
			local s = tostring(baseRaw)
			return (s ~= "" and s ~= "0" and s ~= "0.0" and s ~= "0.00")
		end
		return false
	end

	local baseText
	if baseIsPresent() then
		baseText = (baseN ~= nil) and (fmtNum(baseN) .. "%") or (tostring(baseRaw) .. "%")
	end

	if perN == nil or perN == 0 or not maxLevel or maxLevel <= 0 then
		return baseText and mw.text.nowiki(baseText .. " " .. basis) or nil
	end

	local series = {}
	for lv = 1, maxLevel do
		local perPart = perN * lv

		if baseText and baseN ~= nil then
			local total = baseN + perPart
			table.insert(series, string.format("%s%% %s", fmtNum(total), basis))
		elseif baseText then
			table.insert(series, string.format("%s + %s%% %s", baseText, fmtNum(perPart), basis))
		else
			table.insert(series, string.format("%s%% %s", fmtNum(perPart), basis))
		end
	end

	return dynSpan(series, level)
end

local function formatDamageList(list, maxLevel, level, includeTypePrefix)
	if type(list) ~= "table" or #list == 0 then
		return nil
	end

	local parts = {}
	for _, d in ipairs(list) do
		if type(d) == "table" then
			local txt = formatDamageEntry(d, maxLevel, level)
			if txt then
				if includeTypePrefix and d.Type and d.Type ~= "" then
					table.insert(parts, mw.text.nowiki(tostring(d.Type) .. ": ") .. txt)
				else
					table.insert(parts, txt)
				end
			end
		end
	end

	return (#parts > 0) and table.concat(parts, "<br />") or nil
end

-- Scaling supports either a single dict or a list
local function formatScaling(scaling, basisOverride)
	if type(scaling) ~= "table" then
		return nil
	end

	local list = scaling
	if #list == 0 then
		if scaling.Percent ~= nil or scaling["Scaling ID"] or scaling["Scaling Name"] then
			list = { scaling }
		else
			return nil
		end
	end

	local parts = {}
	for _, s in ipairs(list) do
		if type(s) == "table" then
			local stat = s["Scaling Name"] or s["Scaling ID"] or "Unknown"
			local pct  = s.Percent
			local pctN = toNum(pct)

			local basis = basisOverride or basisLabel(s, false)

			if pctN ~= nil and pctN ~= 0 then
				table.insert(parts, string.format("%s%% %s Per %s", fmtNum(pctN), basis, stat))
			elseif pct ~= nil and tostring(pct) ~= "" and tostring(pct) ~= "0" then
				table.insert(parts, string.format("%s%% %s Per %s", tostring(pct), basis, stat))
			end
		end
	end

	return (#parts > 0) and table.concat(parts, "<br />") or nil
end

local function formatArea(area, maxLevel, level)
	if type(area) ~= "table" then
		return nil
	end

	local parts = {}

	local distLine = valuePairDynamicText("Distance", area["Area Distance"], maxLevel, level, "<br />")
	if distLine then
		table.insert(parts, distLine)
	end

	local size = area["Area Size"]
	if size and size ~= "" then
		table.insert(parts, "Size: " .. mw.text.nowiki(tostring(size)))
	end

	return (#parts > 0) and table.concat(parts, "<br />") or nil
end

local function formatTimingBlock(bt, maxLevel, level)
	if type(bt) ~= "table" then
		return nil
	end

	local parts = {}

	local function add(label, key)
		local block = bt[key]
		if type(block) ~= "table" then
			return
		end
		local lines = valuePairDynamicLines(label, block, maxLevel, level)
		for _, line in ipairs(lines) do
			table.insert(parts, line)
		end
	end

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

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

	return (#parts > 0) and table.concat(parts, "<br />") or nil
end

local function formatResourceCost(rc, maxLevel, level)
	if type(rc) ~= "table" then
		return nil
	end

	local parts = {}

	local manaLines = valuePairDynamicLines("MP", rc["Mana Cost"], maxLevel, level)
	for _, line in ipairs(manaLines) do
		table.insert(parts, line)
	end

	local hpLines = valuePairDynamicLines("HP", rc["Health Cost"], maxLevel, level)
	for _, line in ipairs(hpLines) do
		table.insert(parts, line)
	end

	return (#parts > 0) and table.concat(parts, "<br />") or nil
end

local function formatCombo(combo)
	if type(combo) ~= "table" then
		return nil
	end

	local parts = {}

	if combo.Type then
		table.insert(parts, "Type: " .. mw.text.nowiki(tostring(combo.Type)))
	end

	local durText = formatUnitValue(combo.Duration)
	if durText then
		table.insert(parts, "Duration: " .. mw.text.nowiki(durText))
	end

	if combo.Percent ~= nil then
		local pctText = formatUnitValue(combo.Percent)
		if pctText then
			table.insert(parts, "Bonus: " .. mw.text.nowiki(pctText))
		end
	end

	return (#parts > 0) and table.concat(parts, ", ") or nil
end

local function formatMechanicEffects(effects, maxLevel, level)
	if type(effects) ~= "table" then
		return nil
	end

	local keys = {}
	for k, _ in pairs(effects) do
		table.insert(keys, k)
	end
	table.sort(keys)

	local parts = {}

	local function effectAmount(block)
		if type(block) ~= "table" then
			return nil
		end

		local per = block["Per Level"]
		if type(per) == "table" and #per > 0 then
			if isFlatList(per) then
				return mw.text.nowiki(formatUnitValue(per[1]) or tostring(per[1]))
			end
			local series = {}
			for _, v in ipairs(per) do
				table.insert(series, formatUnitValue(v) or tostring(v))
			end
			return dynSpan(series, level)
		end

		local txt = valuePairRawText(block)
		return txt and mw.text.nowiki(txt) or nil
	end

	for _, name in ipairs(keys) do
		local block = effects[name]
		if type(block) == "table" then
			local t = block.Type

			if t ~= nil and tostring(t) ~= "" then
				local amt = effectAmount(block)
				local seg = mw.text.nowiki(tostring(t) .. " - " .. tostring(name))
				if amt then
					seg = seg .. " + " .. amt
				end
				table.insert(parts, seg)
			else
				local txt = valuePairDynamicText(name, block, maxLevel, level, ", ")
				if txt then
					table.insert(parts, txt)
				end
			end
		end
	end

	return (#parts > 0) and table.concat(parts, "<br />") or nil
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 (#parts > 0) and table.concat(parts, "<br />") or nil
end

local function formatStatusApplications(list, maxLevel, level)
	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 typ  = s.Type or s.Scope or "Target"
			local name = s["Status External Name"] or s["Status Internal Name"] or "Unknown status"

			local seg = tostring(typ) .. " – " .. tostring(name)
			local detail = {}

			if type(s.Duration) == "table" then
				local t = valuePairDynamicText("Duration", s.Duration, maxLevel, level, "; ")
				if t then table.insert(detail, t) end
			end

			if type(s.Chance) == "table" then
				local t = valuePairDynamicText("Chance", s.Chance, maxLevel, level, "; ")
				if t then table.insert(detail, t) end
			end

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

			table.insert(parts, seg)
		end
	end

	return (#parts > 0) and table.concat(parts, "<br />") or nil
end

local function formatStatusRemoval(list, maxLevel, level)
	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 External Name"]
			local label

			if type(names) == "table" then
				label = table.concat(names, ", ")
			elseif type(names) == "string" then
				label = names
			else
				label = "Status"
			end

			local amt
			if type(r["Per Level"]) == "table" and #r["Per Level"] > 0 and not isFlatList(r["Per Level"]) then
				local series = {}
				for _, v in ipairs(r["Per Level"]) do
					table.insert(series, formatUnitValue(v) or tostring(v))
				end
				amt = dynSpan(series, level)
			else
				amt = valuePairRawText(r)
				amt = amt and mw.text.nowiki(amt) or nil
			end

			local seg = mw.text.nowiki(label)
			if amt then
				seg = seg .. " – " .. amt
			end
			table.insert(parts, seg)
		end
	end

	return (#parts > 0) and table.concat(parts, "<br />") or nil
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 Internal Name"] or ev["Skill External Name"] or "Unknown skill"
			table.insert(parts, string.format("%s → %s", action, name))
		end
	end

	return (#parts > 0) and table.concat(parts, "<br />") or nil
end

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

local function skillMatchesUser(rec, userName)
	if type(rec) ~= "table" or not userName or userName == "" then
		return false
	end

	local users = rec.Users
	if type(users) ~= "table" then
		return false
	end

	local userLower = mw.ustring.lower(userName)

	local function listHas(list)
		if type(list) ~= "table" then
			return false
		end
		for _, v in ipairs(list) do
			if type(v) == "string" and mw.ustring.lower(v) == userLower then
				return true
			end
		end
		return false
	end

	return listHas(users.Classes) or listHas(users.Summons) or listHas(users.Monsters) or listHas(users.Events)
end

----------------------------------------------------------------------
-- Direct page detection (hide Users on the skill's own page)
----------------------------------------------------------------------

local function isDirectSkillPage(rec)
	if type(rec) ~= "table" then
		return false
	end

	local pageTitle = mw.title.getCurrentTitle()
	local pageName  = pageTitle and pageTitle.text or ""
	pageName = trim(pageName)
	if not pageName then
		return false
	end

	pageName = mw.ustring.lower(pageName)

	local ext = trim(rec["External Name"] or rec.Name or rec["Display Name"])
	local internal = trim(rec["Internal Name"] or rec.InternalName or rec.InternalID)

	return (ext and mw.ustring.lower(ext) == pageName) or (internal and mw.ustring.lower(internal) == pageName) or false
end

----------------------------------------------------------------------
-- Hero modules (4-slot scaffold)
----------------------------------------------------------------------

local function moduleBox(slot, extraClasses, innerHtml, isEmpty)
	local box = mw.html.create("div")
	box:addClass("hero-module")
	box:addClass("hero-module-" .. tostring(slot))
	box:attr("data-hero-module", tostring(slot))

	if extraClasses then
		if type(extraClasses) == "string" then
			box:addClass(extraClasses)
		elseif type(extraClasses) == "table" then
			for _, c in ipairs(extraClasses) do
				box:addClass(c)
			end
		end
	end

	if isEmpty then
		box:addClass("hero-module-empty")
	end

	local body = box:tag("div"):addClass("hero-module-body")
	if innerHtml and innerHtml ~= "" then
		body:wikitext(innerHtml)
	end

	return tostring(box)
end

local function buildModuleLevelSelector(level, maxLevel)
	local inner = mw.html.create("div")
	inner:addClass("sv-level-ui")

	inner:tag("div")
		:addClass("sv-level-title")
		:wikitext("Level Select")

	inner:tag("div")
		:addClass("sv-level-label")
		:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))

	local slider = inner:tag("div"):addClass("sv-level-slider")
	slider:tag("input")
		:attr("type", "range")
		:attr("min", "1")
		:attr("max", tostring(maxLevel))
		:attr("value", tostring(level))
		:addClass("sv-level-range")
		:attr("aria-label", "Skill level select")

	return moduleBox(1, "module-level-selector", tostring(inner), false)
end

local function buildModuleSkillType(typeBlock)
	typeBlock = (type(typeBlock) == "table") and typeBlock or {}

	local function valName(x)
		if x == nil then
			return nil
		end
		if type(x) == "table" then
			if x.Name and x.Name ~= "" then return tostring(x.Name) end
			if x.ID and x.ID ~= "" then return tostring(x.ID) end
		end
		if type(x) == "string" and x ~= "" then
			return x
		end
		return nil
	end

	local grid = mw.html.create("div")
	grid:addClass("sv-type-grid")

	local added = false
	local function addChunk(label, rawVal)
		local v = valName(rawVal)
		if not v or v == "" then
			return
		end
		added = true

		local chunk = grid:tag("div"):addClass("sv-type-chunk")
		chunk:tag("div"):addClass("sv-type-label"):wikitext(mw.text.nowiki(label))
		chunk:tag("div"):addClass("sv-type-value"):wikitext(mw.text.nowiki(v))
	end

	addChunk("Damage",  typeBlock.Damage  or typeBlock["Damage Type"])
	addChunk("Element", typeBlock.Element or typeBlock["Element Type"])
	addChunk("Target",  typeBlock.Target  or typeBlock["Target Type"])
	addChunk("Cast",    typeBlock.Cast    or typeBlock["Cast Type"])

	local body = added and tostring(grid) or ""
	return moduleBox(2, "module-skill-type", body, false)
end

local function buildEmptyModule(slot)
	return moduleBox(slot, nil, "", true)
end

local function buildHeroModulesUI(rec, level, maxLevel)
	local grid = mw.html.create("div")
	grid:addClass("hero-modules-grid")

	grid:wikitext(buildModuleLevelSelector(level, maxLevel))
	grid:wikitext(buildModuleSkillType(rec.Type or {}))
	grid:wikitext(buildEmptyModule(3))
	grid:wikitext(buildEmptyModule(4))

	return tostring(grid)
end

local function addHeroModulesRow(tbl, modulesUI)
	if not modulesUI or modulesUI == "" then
		return
	end

	local row = tbl:tag("tr")
	row:addClass("hero-modules-row")

	local cell = row:tag("td")
	cell:attr("colspan", 2)
	cell:addClass("hero-modules-cell")
	cell:wikitext(modulesUI)
end

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

local function buildInfobox(rec, opts)
	opts = opts or {}
	local showUsers = (opts.showUsers ~= false)

	local maxLevel = tonumber(rec["Max Level"]) or 1
	if maxLevel < 1 then maxLevel = 1 end
	local level = clamp(maxLevel, 1, maxLevel)

	local root = mw.html.create("table")
	root:addClass("spiritvale-skill-infobox")
	root:addClass("sv-skill-card")
	root:attr("data-max-level", tostring(maxLevel))
	root:attr("data-level", tostring(level))

	if opts.inList then
		root:addClass("sv-skill-inlist")
	end

	-- Useful future hooks (safe, non-breaking)
	local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
	if internalId then
		root:attr("data-skill-id", internalId)
	end

	-- Hero: title bar
	local icon  = rec.Icon
	local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"
	local desc  = rec.Description or ""

	local heroRow = root:tag("tr")
	heroRow:addClass("spiritvale-infobox-main")
	heroRow:addClass("sv-hero-title-row")
	heroRow:addClass("hero-title-bar")

	local heroCell = heroRow:tag("th")
	heroCell:attr("colspan", 2)
	heroCell:addClass("sv-hero-title-cell")

	local heroInner = heroCell:tag("div")
	heroInner:addClass("spiritvale-infobox-main-left-inner")

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

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

	-- Hero: description bar
	if desc ~= "" then
		local descRow = root:tag("tr")
		descRow:addClass("spiritvale-infobox-main")
		descRow:addClass("sv-hero-desc-row")
		descRow:addClass("hero-description-bar")

		local descCell = descRow:tag("td")
		descCell:attr("colspan", 2)
		descCell:addClass("sv-hero-desc-cell")

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

		descInner:tag("div")
			:addClass("spiritvale-infobox-description")
			:wikitext(string.format("''%s''", desc))
	end

	-- Hero modules row (4-slot scaffold)
	addHeroModulesRow(root, buildHeroModulesUI(rec, level, maxLevel))

	-- Users
	if showUsers then
		local users = rec.Users or {}
		addRow(root, "Classes",  listToText(users.Classes),  "sv-row-users", "Users.Classes")
		addRow(root, "Summons",  listToText(users.Summons),  "sv-row-users", "Users.Summons")
		addRow(root, "Monsters", listToText(users.Monsters), "sv-row-users", "Users.Monsters")
		addRow(root, "Events",   listToText(users.Events),   "sv-row-users", "Users.Events")
	end

	-- Requirements
	local req = rec.Requirements or {}
	local hasReq =
		(type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0) or
		(type(req["Required Weapons"]) == "table" and #req["Required Weapons"] > 0) or
		(type(req["Required Stances"]) == "table" and #req["Required Stances"] > 0)

	if hasReq then
		if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
			local skillParts = {}
			for _, rs in ipairs(req["Required Skills"]) do
				local nameReq = rs["Skill External Name"] or rs["Skill Internal Name"] or "Unknown"
				local lvlReq  = rs["Required Level"]
				if lvlReq then
					table.insert(skillParts, string.format("%s (Lv.%s)", nameReq, lvlReq))
				else
					table.insert(skillParts, nameReq)
				end
			end
			addRow(root, "Required Skills", table.concat(skillParts, ", "), "sv-row-req", "Requirements.Required Skills")
		end

		addRow(root, "Required Weapons", listToText(req["Required Weapons"]), "sv-row-req", "Requirements.Required Weapons")
		addRow(root, "Required Stances", listToText(req["Required Stances"]), "sv-row-req", "Requirements.Required Stances")
	end

	-- Mechanics
	local mech = rec.Mechanics or {}
	if next(mech) ~= nil then
		addRow(root, "Range", formatUnitValue(mech.Range), "sv-row-mech", "Mechanics.Range")
		addRow(root, "Area",  formatArea(mech.Area, maxLevel, level), "sv-row-mech", "Mechanics.Area")

		if mech["Autocast Multiplier"] ~= nil then
			addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
		end

		addRow(root, "Timing",            formatTimingBlock(mech["Basic Timings"], maxLevel, level), "sv-row-mech", "Mechanics.Basic Timings")
		addRow(root, "Resource Cost",     formatResourceCost(mech["Resource Cost"], maxLevel, level), "sv-row-mech", "Mechanics.Resource Cost")
		addRow(root, "Combo",             formatCombo(mech.Combo), "sv-row-mech", "Mechanics.Combo")
		addRow(root, "Special Mechanics", formatMechanicEffects(mech.Effects, maxLevel, level), "sv-row-mech", "Mechanics.Effects")
	end

	-- Source (new) + Scaling, with backcompat for old Damage
	if type(rec.Source) == "table" then
		addRow(root, "Source", formatSource(rec.Source, maxLevel, level), "sv-row-source", "Source")

		local basisOverride =
			(rec.Source.Type == "Healing" or rec.Source.Healing == true) and "Healing" or nil

		addRow(root, "Scaling", formatScaling(rec.Source.Scaling, basisOverride), "sv-row-source", "Source.Scaling")
	else
		local dmg = rec.Damage or {}
		if next(dmg) ~= nil then
			local main = dmg["Main Damage"]
			local mainNonHeal, healOnly = {}, {}

			if type(main) == "table" then
				for _, d in ipairs(main) do
					if type(d) == "table" and d.Type == "Healing" then
						table.insert(healOnly, d)
					else
						table.insert(mainNonHeal, d)
					end
				end
			end

			local flatList = dmg["Flat Damage"]
			local reflList = dmg["Reflect Damage"]

			local flatHas = (type(flatList) == "table" and #flatList > 0)
			local reflHas = (type(reflList) == "table" and #reflList > 0)

			local pureHealing = (#healOnly > 0) and (#mainNonHeal == 0) and (not flatHas) and (not reflHas)

			addRow(root, "Main Damage",    formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main Damage")
			addRow(root, "Flat Damage",    formatDamageList(flatList, maxLevel, level, false), "sv-row-source", "Damage.Flat Damage")
			addRow(root, "Reflect Damage", formatDamageList(reflList, maxLevel, level, false), "sv-row-source", "Damage.Reflect Damage")
			addRow(root, "Healing",        formatDamageList(healOnly, maxLevel, level, false), "sv-row-source", "Damage.Healing")

			addRow(root, "Scaling", formatScaling(dmg.Scaling, pureHealing and "Healing" or nil), "sv-row-source", "Damage.Scaling")
		end
	end

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

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

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

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

	return tostring(root)
end

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

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

	local userName = args.user or args[1]
	if not userName or userName == "" then
		userName = mw.title.getCurrentTitle().text
	end

	if not userName or userName == "" then
		return "<strong>No user name provided to Skill list.</strong>"
	end

	local dataset = getSkills()
	local matches = {}

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

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

	local root = mw.html.create("div")
	root:addClass("sv-skill-collection")

	for _, rec in ipairs(matches) do
		local item = root:tag("div"):addClass("sv-skill-item")
		item:wikitext(buildInfobox(rec, { showUsers = false, inList = true }))
	end

	return tostring(root)
end

----------------------------------------------------------------------
-- Public: single-skill 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
	if name and name ~= "" then
		rec = findSkillByName(name)
	end
	if not rec and id and id ~= "" then
		rec = getSkillById(id)
	end

	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 Skill:</strong> %s[[Category:Pages with unknown skill|%s]]",
			mw.text.nowiki(label),
			label
		)
	end

	local showUsers = not isDirectSkillPage(rec)
	return buildInfobox(rec, { showUsers = showUsers })
end

return p