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
 
(22 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:GameSkills
-- Module:GameSkills
--
--
-- Renders active skill data (Data:skills.json) into an infobox-style table,
-- Phase 6.5+ (Plug-in Slot Architecture)
-- and can also list all skills for a given user/class (page name).
--
--
-- Standard Hero Layout (reused across the wiki):
-- Layout:
--  1) hero-title-bar        (icon + name)
--  1) hero-title-bar        (TOP BAR, 2 slots: herobar 1..2)
--  2) hero-description-bar  (description strip)
--  2) hero-description-bar  (description strip)
--  3) hero-modules row      (4 slots: hero-module-1..4)
--  3) hero-modules row      (4 slots: hero-module-1..4)
--        - Slot 1: module-level-selector
--        - Slot 2: module-skill-type
--        - Slot 3: skill-source-module (Basis + Source + Scaling) OR blank
--        - Slot 4: module-quick-stats (Range/Area/Cost/Cast/Cooldown/Duration)
--
--
-- Requires Common.js logic that updates:
-- Requires Common.js:
--  - .sv-dyn spans via data-series
--  - updates .sv-dyn spans via data-series
--  - .sv-level-num + data-level on .sv-skill-card
--  - updates .sv-level-num + data-level on .sv-skill-card
--  - binds to input.sv-level-range inside each card
--  - binds to input.sv-level-range inside each card


Line 28: Line 23:
local skillsCache
local skillsCache


-- getSkills: lazy-load + cache skill dataset from GameData.
local function getSkills()
local function getSkills()
if not skillsCache then
if not skillsCache then
Line 39: Line 35:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- getArgs: read args from parent frame when invoked from a template.
local function getArgs(frame)
local function getArgs(frame)
local parent = frame:getParent()
local parent = frame:getParent()
Line 44: Line 41:
end
end


-- trim: normalize strings (nil if empty).
local function trim(s)
local function trim(s)
if type(s) ~= "string" then
if type(s) ~= "string" then
Line 52: Line 50:
end
end


-- toNum: convert common scalar/table forms to a Lua number.
local function toNum(v)
local function toNum(v)
if type(v) == "number" then
if type(v) == "number" then
Line 65: Line 64:
end
end


-- clamp: clamp a number into [lo, hi].
local function clamp(n, lo, hi)
local function clamp(n, lo, hi)
if type(n) ~= "number" then
if type(n) ~= "number" then
Line 74: Line 74:
end
end


-- fmtNum: consistent number formatting (trim trailing zeros).
local function fmtNum(n)
local function fmtNum(n)
if type(n) ~= "number" then
if type(n) ~= "number" then
Line 89: Line 90:
end
end


-- listToText: join an array into a readable string.
local function listToText(list, sep)
local function listToText(list, sep)
if type(list) ~= "table" or #list == 0 then
if type(list) ~= "table" or #list == 0 then
Line 96: Line 98:
end
end


-- isNoneLike: treat common "none" spellings as empty.
local function isNoneLike(v)
local function isNoneLike(v)
if v == nil then return true end
if v == nil then return true end
Line 104: Line 107:
end
end


-- Add a labeled infobox row (with optional hooks for future)
-- addRow: add a standard <tr><th>Label</th><td>Value</td></tr> row.
local function addRow(tbl, label, value, rowClass, dataKey)
local function addRow(tbl, label, value, rowClass, dataKey)
if value == nil or value == "" then
if value == nil or value == "" then
Line 112: Line 115:
local row = tbl:tag("tr")
local row = tbl:tag("tr")
row:addClass("sv-row")
row:addClass("sv-row")
if rowClass then
if rowClass then row:addClass(rowClass) end
row:addClass(rowClass)
if dataKey then row:attr("data-field", dataKey) end
end
if dataKey then
row:attr("data-field", dataKey)
end


row:tag("th"):wikitext(label):done()
row:tag("th"):wikitext(label):done()
Line 123: Line 122:
end
end


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


-- dynSpan: render a JS-updated span for a level series.
local function dynSpan(series, level)
local function dynSpan(series, level)
if type(series) ~= "table" or #series == 0 then
if type(series) ~= "table" or #series == 0 then
Line 167: Line 166:
end
end


-- isFlatList: true if all values in list are identical.
local function isFlatList(list)
local function isFlatList(list)
if type(list) ~= "table" or #list == 0 then
if type(list) ~= "table" or #list == 0 then
Line 180: Line 180:
end
end


-- isNonZeroScalar: detect if a value is present and not effectively zero.
local function isNonZeroScalar(v)
local function isNonZeroScalar(v)
if v == nil then
if v == nil then return false end
return false
if type(v) == "number" then return v ~= 0 end
end
if type(v) == "number" then
return v ~= 0
end
if type(v) == "string" then
if type(v) == "string" then
local n = tonumber(v)
local n = tonumber(v)
if n == nil then
if n == nil then return v ~= "" end
return v ~= ""
end
return n ~= 0
return n ~= 0
end
end
Line 200: Line 195:
end
end


-- isZeroish: aggressively treat common “zero” text forms as zero.
local function isZeroish(v)
local function isZeroish(v)
if v == nil then return true end
if v == nil then return true end
Line 206: Line 202:
return isZeroish(v.Value)
return isZeroish(v.Value)
end
end
local s = mw.text.trim(tostring(v))
local s = mw.text.trim(tostring(v))
if s == "" then return true end
if s == "" then return true end
-- common “0” shapes once wikiprep has stringified
if s == "0" or s == "0.0" or s == "0.00" then return true end
if s == "0" or s == "0.0" or s == "0.00" then return true end
if s == "0s" or s == "0 s" then return true end
if s == "0s" or s == "0 s" then return true end
if s == "0m" or s == "0 m" then return true end
if s == "0m" or s == "0 m" then return true end
if s == "0%" or s == "0 %" then return true end
if s == "0%" or s == "0 %" then return true end
-- numeric-with-unit like "0.0000s"
 
local n = tonumber((mw.ustring.gsub(s, "[^0-9%.%-]", "")))
local n = tonumber((mw.ustring.gsub(s, "[^0-9%.%-]", "")))
return (n ~= nil and n == 0)
return (n ~= nil and n == 0)
end
end


-- Base/Per Level renderer:
-- valuePairRawText: render Base/Per Level blocks into readable text (fallback).
-- - 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)
local function valuePairRawText(block)
if type(block) ~= "table" then
if type(block) ~= "table" then
Line 305: Line 248:
end
end


-- Prefer “value only” (dynSpan when series list exists)
-- valuePairDynamicValueOnly: render Base/Per Level blocks using dyn spans where possible.
local function valuePairDynamicValueOnly(block, maxLevel, level)
local function valuePairDynamicValueOnly(block, maxLevel, level)
if type(block) ~= "table" then
if type(block) ~= "table" then
Line 341: Line 284:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- getSkillById: locate a skill by internal ID.
local function getSkillById(id)
local function getSkillById(id)
id = trim(id)
id = trim(id)
if not id then
if not id then return nil end
return nil
end
local dataset = getSkills()
local dataset = getSkills()
return (dataset.byId or {})[id]
return (dataset.byId or {})[id]
end
end


-- findSkillByName: locate a skill by external/display name.
local function findSkillByName(name)
local function findSkillByName(name)
name = trim(name)
name = trim(name)
if not name then
if not name then return nil end
return nil
end


local dataset = getSkills()
local dataset = getSkills()
Line 375: Line 316:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Formatting helpers
-- Legacy damage helpers
----------------------------------------------------------------------
----------------------------------------------------------------------


-- basisLabel: label ATK/MATK basis in legacy damage blocks.
local function basisLabel(entry, isHealing)
local function basisLabel(entry, isHealing)
if isHealing then
if isHealing then
Line 397: Line 339:
end
end


-- BACKCOMPAT: old damage list formatter (for infobox rows)
-- formatDamageEntry: legacy percent damage formatting (dynamic by level).
local function formatDamageEntry(entry, maxLevel, level)
local function formatDamageEntry(entry, maxLevel, level)
if type(entry) ~= "table" then
if type(entry) ~= "table" then
Line 449: Line 391:
end
end


-- formatDamageList: render a list of legacy damage entries into <br/> blocks.
local function formatDamageList(list, maxLevel, level, includeTypePrefix)
local function formatDamageList(list, maxLevel, level, includeTypePrefix)
if type(list) ~= "table" or #list == 0 then
if type(list) ~= "table" or #list == 0 then
Line 465: Line 408:
end
end
end
end
end
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, suppressDurationIndex)
if type(list) ~= "table" or #list == 0 then
return nil
end
local parts = {}
for idx, 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 = {}
-- Duration (optionally suppressed if Module 4 “promoted” it)
if idx ~= suppressDurationIndex and 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
end
end
Line 683: Line 415:


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


-- skillMatchesUser: check if a skill is used by a specific class/monster/summon/event.
local function skillMatchesUser(rec, userName)
local function skillMatchesUser(rec, userName)
if type(rec) ~= "table" or not userName or userName == "" then
if type(rec) ~= "table" or not userName or userName == "" then
Line 714: Line 447:


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


-- isDirectSkillPage: hide Users rows when viewing the skill page itself.
local function isDirectSkillPage(rec)
local function isDirectSkillPage(rec)
if type(rec) ~= "table" then
if type(rec) ~= "table" then
Line 738: Line 472:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Hero modules (4-slot scaffold)
-- Slot config (edit these tables only to rearrange layout)
----------------------------------------------------------------------
 
local HERO_BAR_SLOT_ASSIGNMENT = {
[1] = "IconName",
[2] = "SkillType", -- Damage/Element/Hits/Target/Cast/Combo strip
}
 
local HERO_MODULE_SLOT_ASSIGNMENT = {
[1] = "SourceType",
[2] = "QuickStats",
[3] = "SpecialMechanics",
[4] = "LevelSelector",
}
 
----------------------------------------------------------------------
-- Slot scaffolds
----------------------------------------------------------------------
----------------------------------------------------------------------


local function moduleBox(slot, extraClasses, innerHtml, isEmpty)
-- heroBarBox: wrapper for hero-bar slot modules.
local function heroBarBox(slot, extraClasses, innerHtml, isEmpty)
local box = mw.html.create("div")
local box = mw.html.create("div")
box:addClass("hero-module")
box:addClass("hero-bar-module")
box:addClass("hero-module-" .. tostring(slot))
box:addClass("hero-bar-module-" .. tostring(slot))
box:attr("data-hero-module", tostring(slot))
box:attr("data-hero-bar-module", tostring(slot))
 
if slot == 2 then
box:addClass("sv-herobar-compact")
end


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


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


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


local function buildEmptyModule(slot)
-- moduleBox: wrapper for hero-module (2x2 grid) slot modules.
return moduleBox(slot, nil, "", true)
local function moduleBox(slot, extraClasses, innerHtml, isEmpty)
end
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
-- Module 1 – Level Selector
if type(extraClasses) == "string" then
-- ------------------------------------------------------------
box:addClass(extraClasses)
local function buildModuleLevelSelector(level, maxLevel)
elseif type(extraClasses) == "table" then
local inner = mw.html.create("div")
for _, c in ipairs(extraClasses) do box:addClass(c) end
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")
 
if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
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")
else
inner:addClass("sv-level-ui-single")
slider:addClass("sv-level-slider-single")
end
 
return moduleBox(1, "module-level-selector", tostring(inner), false)
end
 
-- ------------------------------------------------------------
-- Module 2 – Skill Type
-- (If non-damaging, hide Damage + Element; keep Target + Cast)
-- ------------------------------------------------------------
local function buildModuleSkillType(typeBlock, hideDamageAndElement)
typeBlock = (type(typeBlock) == "table") and typeBlock or {}
 
local function valName(x)
if x == nil then
return nil
end
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
if x.Value ~= nil then return tostring(x.Value) end
end
if type(x) == "string" and x ~= "" then
return x
end
return nil
end
end


local grid = mw.html.create("div")
if isEmpty then
grid:addClass("sv-type-grid")
box:addClass("hero-module-empty")
 
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
end


if not hideDamageAndElement then
local body = box:tag("div"):addClass("hero-module-body")
addChunk("Damage",  typeBlock.Damage  or typeBlock["Damage Type"])
if innerHtml and innerHtml ~= "" then
addChunk("Element", typeBlock.Element or typeBlock["Element Type"])
body:wikitext(innerHtml)
end
end


addChunk("Target",  typeBlock.Target  or typeBlock["Target Type"])
return tostring(box)
addChunk("Cast",    typeBlock.Cast    or typeBlock["Cast Type"])
 
local body = added and tostring(grid) or ""
return moduleBox(2, "module-skill-type", body, false)
end
end


-- ============================================================
----------------------------------------------------------------------
-- Module 3 – Skill Source (Modifier + Source + Scaling)
-- Shared helpers (Source + QuickStats + SpecialMechanics)
-- (unchanged from your current system)
----------------------------------------------------------------------
-- ============================================================


-- formatScalingCompactLines: build compact “Scaling” lines for SourceType.
local function formatScalingCompactLines(scaling)
local function formatScalingCompactLines(scaling)
if type(scaling) ~= "table" then
if type(scaling) ~= "table" then
Line 893: Line 586:
end
end


-- basisWordFromFlags: compute “Attack/Magic/Hybrid” from ATK/MATK booleans.
local function basisWordFromFlags(atkFlag, matkFlag)
local function basisWordFromFlags(atkFlag, matkFlag)
if atkFlag and matkFlag then
if atkFlag and matkFlag then
Line 904: Line 598:
end
end


local function sourceValueForLevel(src, maxLevel, level)
-- legacyPercentAtLevel: compute “Base% + PerLevel%*level” for legacy entries.
if type(src) ~= "table" then
return nil
end
 
local base = src.Base
local per  = src["Per Level"]
 
if type(per) == "table" and #per > 0 then
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
 
return valuePairDynamicValueOnly(src, maxLevel, level)
end
 
local function legacyPercentAtLevel(entry, level)
local function legacyPercentAtLevel(entry, level)
if type(entry) ~= "table" then
if type(entry) ~= "table" then
Line 954: Line 624:
end
end


local function buildModuleSkillSource(rec, level, maxLevel)
-- seriesFromValuePair: normalize Base/Per Level blocks into a level-indexed series.
local basisWord = nil
local sourceKind = nil
local sourceVal  = nil
local scaling    = nil
 
if type(rec.Source) == "table" then
local src = rec.Source
local atkFlag  = (src["ATK-Based"] == true)
local matkFlag = (src["MATK-Based"] == true)
basisWord = basisWordFromFlags(atkFlag, matkFlag)
 
sourceKind = src.Type or ((src.Healing == true) and "Healing") or "Damage"
sourceVal  = sourceValueForLevel(src, maxLevel, level)
scaling    = src.Scaling
end
 
if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
local dmg = rec.Damage
scaling = scaling or dmg.Scaling
 
local main = dmg["Main Damage"]
local refl = dmg["Reflect Damage"]
local flat = dmg["Flat Damage"]
 
if type(main) == "table" and #main > 0 then
local pick = nil
for _, d in ipairs(main) do
if type(d) == "table" and d.Type ~= "Healing" then
pick = d
break
end
end
pick = pick or main[1]
 
if type(pick) == "table" then
local atkFlag  = (pick["ATK-Based"] == true)
local matkFlag = (pick["MATK-Based"] == true)
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)
 
sourceKind = (pick.Type == "Healing") and "Healing" or "Damage"
sourceVal  = legacyPercentAtLevel(pick, level)
end
elseif type(refl) == "table" and #refl > 0 and type(refl[1]) == "table" then
local pick = refl[1]
local atkFlag  = (pick["ATK-Based"] == true)
local matkFlag = (pick["MATK-Based"] == true)
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)
 
sourceKind = "Reflect"
sourceVal  = legacyPercentAtLevel(pick, level)
elseif type(flat) == "table" and #flat > 0 and type(flat[1]) == "table" then
local pick = flat[1]
local atkFlag  = (pick["ATK-Based"] == true)
local matkFlag = (pick["MATK-Based"] == true)
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)
 
sourceKind = "Flat"
sourceVal  = legacyPercentAtLevel(pick, level)
end
end
 
local scalingLines = formatScalingCompactLines(scaling)
local hasSource    = (sourceVal ~= nil and tostring(sourceVal) ~= "")
local hasScaling  = (type(scalingLines) == "table" and #scalingLines > 0)
 
if (not hasSource) and (not hasScaling) then
return nil
end
 
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")
 
local extra = { "skill-source-module" }
table.insert(extra, hasMod and "sv-has-mod" or "sv-no-mod")
 
if hasSource and (not hasScaling) then
table.insert(extra, "sv-only-source")
elseif hasScaling and (not hasSource) then
table.insert(extra, "sv-only-scaling")
end
 
local wrap = mw.html.create("div")
wrap:addClass("sv-source-grid")
 
if hasMod then
local modCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-modifier")
modCol:tag("div"):addClass("sv-source-pill"):wikitext("Modifier")
modCol:tag("div"):addClass("sv-modifier-value"):wikitext(mw.text.nowiki(basisWord))
end
 
if hasSource then
local sourceCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-main")
sourceCol:tag("div"):addClass("sv-source-pill"):wikitext(mw.text.nowiki(sourceKind or "Source"))
sourceCol:tag("div"):addClass("sv-source-value"):wikitext(sourceVal)
end
 
if hasScaling then
local scalingCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-scaling")
scalingCol:tag("div"):addClass("sv-source-pill"):wikitext("Scaling")
 
local list = scalingCol:tag("div"):addClass("sv-scaling-list")
for _, line in ipairs(scalingLines) do
list:tag("div"):addClass("sv-scaling-item"):wikitext(mw.text.nowiki(line))
end
end
 
return moduleBox(3, extra, tostring(wrap), false)
end
 
----------------------------------------------------------------------
-- Module 4 – Quick Stats (3x2)
-- Range / Area / Cost / Cast Time / Cooldown / Duration
-- - Zero values => —
-- - Area “none/missing” => —
-- - Cost: HP only shows if non-zero at some level; never show "0 HP"
-- - Special: if Duration missing AND skill is non-damaging, promote a status-application duration
----------------------------------------------------------------------
 
local function seriesFromValuePair(block, maxLevel)
local function seriesFromValuePair(block, maxLevel)
if type(block) ~= "table" then
if type(block) ~= "table" then
Line 1,095: Line 648:
local series = {}
local series = {}


-- Preferred: expanded series (wikiprep)
-- Expanded per-level series (wikiprep)
if type(per) == "table" and #per > 0 then
if type(per) == "table" and #per > 0 then
for lv = 1, maxLevel do
for lv = 1, maxLevel do
Line 1,152: Line 705:
end
end


-- displayFromSeries: render a series as fixed text or dynSpan (nil if all “—”).
local function displayFromSeries(series, level)
local function displayFromSeries(series, level)
if type(series) ~= "table" or #series == 0 then
if type(series) ~= "table" or #series == 0 then
Line 1,174: Line 728:
end
end


local function formatAreaSize(area)
-- formatAreaSize: human readable area sizing for QuickStats.
-- Shows: "<Area Size> (<number>)" e.g. "Medium (4)"
local function formatAreaSize(area, maxLevel, level)
if type(area) ~= "table" then
if type(area) ~= "table" then
return nil
return nil
end
end


local raw = area["Area Size"]
-- Helper: pull a number from scalar/unit/valuepair-ish things.
if raw == nil then
local function extractNumber(v)
if v == nil then return nil end
 
-- Unit block {Value, Unit}
if type(v) == "table" and v.Value ~= nil then
local n = toNum(v.Value)
return n
end
 
-- ValuePair {Base, Per Level} -> prefer Base at current level if series exists
if type(v) == "table" and (v.Base ~= nil or v["Per Level"] ~= nil) then
local s = seriesFromValuePair(v, maxLevel or 1)
if type(s) == "table" and #s > 0 then
local idx = clamp(level or 1, 1, #s)
local txt = s[idx]
if txt and txt ~= "—" then
-- try parse numeric from string (e.g. "4 tiles" -> 4)
local num = tonumber((mw.ustring.gsub(tostring(txt), "[^0-9%.%-]", "")))
return num
end
end
return nil
end
 
-- Plain scalar
if type(v) == "number" then return v end
if type(v) == "string" then
local num = tonumber((mw.ustring.gsub(mw.text.trim(v), "[^0-9%.%-]", "")))
return num
end
 
return nil
return nil
end
end


local name, num
-- 1) Read Area Size label/name
local rawSize = area["Area Size"]
if rawSize == nil then
return nil
end


if type(raw) == "table" then
local sizeName = nil
name = raw.Name or raw.ID or raw.Value
if type(rawSize) == "table" then
num  = raw.Value
sizeName = rawSize.Name or rawSize.ID or rawSize.Value
-- if name accidentally came from Value, prefer Name/ID if present
elseif type(rawSize) == "string" then
if raw.Name or raw.ID then
sizeName = rawSize
name = raw.Name or raw.ID
elseif type(rawSize) == "number" then
-- uncommon; treat as numeric-only label
sizeName = tostring(rawSize)
end
 
sizeName = sizeName and mw.text.trim(tostring(sizeName)) or nil
if not sizeName or sizeName == "" or isNoneLike(sizeName) then
return nil
end
 
-- 2) Find the numeric “exact number” to append
-- Prefer the explicit Area Distance block, then fall back to other known numeric keys.
local num = nil
 
local dist = area["Area Distance"]
if type(dist) == "table" then
-- Prefer Effective Distance if present and non-zero, else Base
num = extractNumber(dist["Effective Distance"]) or extractNumber(dist.Effective) or extractNumber(dist["Effective"])
if not num or num == 0 then
num = extractNumber(dist.Base) or extractNumber(dist["Base"])
end
end
elseif type(raw) == "string" then
name = raw
elseif type(raw) == "number" then
num = raw
end
end


-- fallback keys that sometimes exist depending on dict/wikiprep
if not num or num == 0 then
if num == nil then
num =
num = toNum(area["Area Value"]) or toNum(area["Area Size Value"]) or toNum(area["Area Number"]) or toNum(area["Area Radius"])
extractNumber(area["Area Value"]) or
extractNumber(area["Area Size Value"]) or
extractNumber(area["Area Number"]) or
extractNumber(area["Area Radius"])
end
end


if name ~= nil then
-- 3) Render
local s = mw.text.trim(tostring(name))
-- If size already contains parentheses, assume it already includes the numeric.
if s == "" or isNoneLike(s) then
if mw.ustring.find(sizeName, "%(") then
return nil
return mw.text.nowiki(sizeName)
end
-- if already contains "(...)" keep it
if mw.ustring.find(s, "%(") then
return mw.text.nowiki(s)
end
if num ~= nil and num ~= 0 then
return mw.text.nowiki(string.format("%s (%s)", s, fmtNum(num)))
end
return mw.text.nowiki(s)
end
end


if num ~= nil and num ~= 0 then
if num and num ~= 0 then
return mw.text.nowiki(string.format("(%s)", fmtNum(num)))
return mw.text.nowiki(string.format("%s (%s)", sizeName, fmtNum(num)))
end
end


return nil
return mw.text.nowiki(sizeName)
end
end


-- skillHasAnyDamage: determine if a skill has any meaningful damage (for non-damaging rules).
local function skillHasAnyDamage(rec, maxLevel)
local function skillHasAnyDamage(rec, maxLevel)
-- “Has damage/source” for our UI decisions:
-- - New Source with any non-zero at any level
-- - OR legacy damage lists present
if type(rec.Source) == "table" then
if type(rec.Source) == "table" then
local s = seriesFromValuePair(rec.Source, maxLevel)
local s = seriesFromValuePair(rec.Source, maxLevel)
Line 1,252: Line 849:
end
end


local function buildModuleQuickStats(rec, level, maxLevel, promo)
-- computeDurationPromotion: promote status-duration into QuickStats when a skill is non-damaging.
local function computeDurationPromotion(rec, maxLevel)
if type(rec) ~= "table" then return nil end
if skillHasAnyDamage(rec, maxLevel) then return nil end
 
local mech = (type(rec.Mechanics) == "table") and rec.Mechanics or {}
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
local durS = seriesFromValuePair(bt["Duration"], maxLevel)
 
if durS ~= nil then
local any = false
for _, v in ipairs(durS) do
if v ~= "—" then any = true break end
end
if any then
return nil
end
end
 
local apps = rec["Status Applications"]
if type(apps) ~= "table" then return nil end
 
for idx, app in ipairs(apps) do
if type(app) == "table" and type(app.Duration) == "table" then
local s = seriesFromValuePair(app.Duration, maxLevel)
if s then
for _, v in ipairs(s) do
if v ~= "—" then
return {
durationBlock = app.Duration,
suppressDurationIndex = idx,
}
end
end
end
end
end
 
return nil
end
 
----------------------------------------------------------------------
-- Plug-ins
----------------------------------------------------------------------
 
local PLUGINS = {}
 
-- PLUGIN: IconName (Hero Bar Slot 1) - icon + name.
function PLUGINS.IconName(rec, ctx)
local icon  = rec.Icon
local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"
 
local wrap = mw.html.create("div")
wrap:addClass("sv-herobar-1-wrap")
 
if icon and icon ~= "" then
wrap:tag("div")
:addClass("sv-herobar-icon")
:wikitext(string.format("[[File:%s|80px|link=]]", icon))
end
 
wrap:tag("div")
:addClass("spiritvale-infobox-title")
:wikitext(title)
 
return {
inner = tostring(wrap),
classes = "module-herobar-1",
}
end
 
-- PLUGIN: SkillType (Hero Bar Slot 2) - 2 rows x 3 cells (desktop + mobile).
-- Rules:
--  - If skill is non-damaging, hide Damage/Element/Hits.
--  - If Hits is empty, hide Hits.
--  - If Combo is empty, hide Combo.
-- Ordering:
--  - Desktop: Damage, Element, Hits, Target, Cast, Combo
--  - Mobile:  Damage, Element, Target, Cast, Hits, Combo (CSS reorder)
function PLUGINS.SkillType(rec, ctx)
local typeBlock = (type(rec.Type) == "table") and rec.Type or {}
local mech      = (type(rec.Mechanics) == "table") and rec.Mechanics or {}
 
local level    = ctx.level or 1
local maxLevel = ctx.maxLevel or 1
 
local hideDamageBundle = (ctx.nonDamaging == true)
 
-- valName: extract a display string from typical {Name/ID/Value} objects.
-- NOTE: Includes number support so Hits=2 (number) doesn't get dropped.
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
if x.Value ~= nil then return tostring(x.Value) end
end
if type(x) == "number" then
return tostring(x)
end
if type(x) == "string" and x ~= "" then
return x
end
return nil
end
 
-- hitsDisplay: find + render Hits from multiple possible structured locations.
local function hitsDisplay()
local effects = (type(mech.Effects) == "table") and mech.Effects or {}
 
local h =
typeBlock.Hits or typeBlock["Hits"] or typeBlock["Hit Count"] or typeBlock["Hits Count"] or
mech.Hits or mech["Hits"] or mech["Hit Count"] or mech["Hits Count"] or
effects.Hits or effects["Hits"] or effects["Hit Count"] or effects["Hits Count"] or
rec.Hits or rec["Hits"]
 
if h == nil or isNoneLike(h) then
return nil
end
 
-- ValuePair-style table (Base/Per Level) => dynamic series
if type(h) == "table" then
if h.Base ~= nil or h["Per Level"] ~= nil or type(h["Per Level"]) == "table" then
return displayFromSeries(seriesFromValuePair(h, maxLevel), level)
end
 
-- Unit block {Value, Unit}
if h.Value ~= nil then
local t = formatUnitValue(h)
return t and mw.text.nowiki(t) or nil
end
 
-- Fallback name extraction
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
if x.Value ~= nil then return tostring(x.Value) end
end
if type(x) == "number" then return tostring(x) end
if type(x) == "string" and x ~= "" then return x end
return nil
end
 
local vn = valName(h)
if vn and not isNoneLike(vn) then
return mw.text.nowiki(vn)
end
end
 
-- Scalar number/string
if type(h) == "number" then
return mw.text.nowiki(fmtNum(h))
end
if type(h) == "string" then
local t = trim(h)
return (t and not isNoneLike(t)) and mw.text.nowiki(t) or nil
end
 
return nil
end
 
-- comboDisplay: render Combo as a compact text block (Type (+ details)).
local function comboDisplay()
local c = (type(mech.Combo) == "table") and mech.Combo or nil
if not c then return nil end
 
local typ = trim(c.Type)
if not typ or isNoneLike(typ) then
return nil
end
 
local details = {}
 
local pct = formatUnitValue(c.Percent)
if pct and not isZeroish(pct) then
table.insert(details, mw.text.nowiki(pct))
end
 
local dur = formatUnitValue(c.Duration)
if dur and not isZeroish(dur) then
table.insert(details, mw.text.nowiki(dur))
end
 
if #details > 0 then
return mw.text.nowiki(typ) .. " (" .. table.concat(details, ", ") .. ")"
end
return mw.text.nowiki(typ)
end
 
local grid = mw.html.create("div")
grid:addClass("sv-type-grid")
grid:addClass("sv-compact-root")
 
local added = false
 
-- addChunk: add one labeled value cell (key drives CSS ordering).
local function addChunk(key, label, valueHtml)
if valueHtml == nil or valueHtml == "" then return end
added = true
 
local chunk = grid:tag("div")
:addClass("sv-type-chunk")
:addClass("sv-type-" .. tostring(key))
:attr("data-type-key", tostring(key))
 
chunk:tag("div")
:addClass("sv-type-label")
:wikitext(mw.text.nowiki(label))
 
chunk:tag("div")
:addClass("sv-type-value")
:wikitext(valueHtml)
end
 
-- Damage + Element + Hits bundle (hidden when non-damaging)
if not hideDamageBundle then
local dmg  = valName(typeBlock.Damage or typeBlock["Damage Type"])
local ele  = valName(typeBlock.Element or typeBlock["Element Type"])
local hits = hitsDisplay()
 
if dmg and not isNoneLike(dmg) then
addChunk("damage", "Damage", mw.text.nowiki(dmg))
end
if ele and not isNoneLike(ele) then
addChunk("element", "Element", mw.text.nowiki(ele))
end
if hits then
addChunk("hits", "Hits", hits)
end
end
 
-- Target + Cast
local tgt = valName(typeBlock.Target or typeBlock["Target Type"])
local cst = valName(typeBlock.Cast  or typeBlock["Cast Type"])
 
if tgt and not isNoneLike(tgt) then
addChunk("target", "Target", mw.text.nowiki(tgt))
end
if cst and not isNoneLike(cst) then
addChunk("cast", "Cast", mw.text.nowiki(cst))
end
 
-- Combo
local combo = comboDisplay()
if combo then
addChunk("combo", "Combo", combo)
end
 
return {
inner = added and tostring(grid) or "",
classes = "module-skill-type",
}
end
 
-- PLUGIN: SourceType (Hero Module Slot 1) - Modifier + Source + Scaling.
function PLUGINS.SourceType(rec, ctx)
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1
 
local basisWord = nil
local sourceKind = nil
local sourceVal  = nil
local scaling    = nil
 
-- sourceValueForLevel: dynamic formatting for structured Source blocks.
local function sourceValueForLevel(src)
if type(src) ~= "table" then
return nil
end
 
local per = src["Per Level"]
if type(per) == "table" and #per > 0 then
if isFlatList(per) then
local one  = formatUnitValue(per[1]) or tostring(per[1])
local show = formatUnitValue(src.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
 
return valuePairDynamicValueOnly(src, maxLevel, level)
end
 
if type(rec.Source) == "table" then
local src = rec.Source
local atkFlag  = (src["ATK-Based"] == true)
local matkFlag = (src["MATK-Based"] == true)
basisWord = basisWordFromFlags(atkFlag, matkFlag)
 
sourceKind = src.Type or ((src.Healing == true) and "Healing") or "Damage"
sourceVal  = sourceValueForLevel(src)
scaling    = src.Scaling
end
 
-- Fallback to legacy Damage lists if Source absent
if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
local dmg = rec.Damage
scaling = scaling or dmg.Scaling
 
local main = dmg["Main Damage"]
local refl = dmg["Reflect Damage"]
local flat = dmg["Flat Damage"]
 
if type(main) == "table" and #main > 0 then
local pick = nil
for _, d in ipairs(main) do
if type(d) == "table" and d.Type ~= "Healing" then
pick = d
break
end
end
pick = pick or main[1]
 
if type(pick) == "table" then
local atkFlag  = (pick["ATK-Based"] == true)
local matkFlag = (pick["MATK-Based"] == true)
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)
 
sourceKind = (pick.Type == "Healing") and "Healing" or "Damage"
sourceVal  = legacyPercentAtLevel(pick, level)
end
elseif type(refl) == "table" and #refl > 0 and type(refl[1]) == "table" then
local pick = refl[1]
local atkFlag  = (pick["ATK-Based"] == true)
local matkFlag = (pick["MATK-Based"] == true)
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)
 
sourceKind = "Reflect"
sourceVal  = legacyPercentAtLevel(pick, level)
elseif type(flat) == "table" and #flat > 0 and type(flat[1]) == "table" then
local pick = flat[1]
local atkFlag  = (pick["ATK-Based"] == true)
local matkFlag = (pick["MATK-Based"] == true)
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)
 
sourceKind = "Flat"
sourceVal  = legacyPercentAtLevel(pick, level)
end
end
 
local scalingLines = formatScalingCompactLines(scaling)
local hasSource    = (sourceVal ~= nil and tostring(sourceVal) ~= "")
local hasScaling  = (type(scalingLines) == "table" and #scalingLines > 0)
 
if (not hasSource) and (not hasScaling) then
return nil
end
 
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")
 
local extra = { "skill-source-module" }
table.insert(extra, hasMod and "sv-has-mod" or "sv-no-mod")
 
if hasSource and (not hasScaling) then
table.insert(extra, "sv-only-source")
elseif hasScaling and (not hasSource) then
table.insert(extra, "sv-only-scaling")
end
 
local wrap = mw.html.create("div")
wrap:addClass("sv-source-grid")
wrap:addClass("sv-compact-root")
 
if hasMod then
local modCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-modifier")
modCol:tag("div"):addClass("sv-source-pill"):wikitext("Modifier")
modCol:tag("div"):addClass("sv-modifier-value"):wikitext(mw.text.nowiki(basisWord))
end
 
if hasSource then
local sourceCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-main")
sourceCol:tag("div"):addClass("sv-source-pill"):wikitext(mw.text.nowiki(sourceKind or "Source"))
sourceCol:tag("div"):addClass("sv-source-value"):wikitext(sourceVal)
end
 
if hasScaling then
local scalingCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-scaling")
scalingCol:tag("div"):addClass("sv-source-pill"):wikitext("Scaling")
 
local list = scalingCol:tag("div"):addClass("sv-scaling-list")
for _, line in ipairs(scalingLines) do
list:tag("div"):addClass("sv-scaling-item"):wikitext(mw.text.nowiki(line))
end
end
 
return {
inner = tostring(wrap),
classes = extra,
}
end
 
-- PLUGIN: QuickStats (Hero Module Slot 2) - 3x2 grid (range/area/cost/cast/cd/duration).
-- NOTE: Hits does NOT live here (it lives in SkillType).
function PLUGINS.QuickStats(rec, ctx)
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1
local promo = ctx.promo
 
local mech = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
local mech = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
Line 1,268: Line 1,269:
end
end
else
else
-- non-numeric range (rare) – show unless “none”
local t = mw.text.trim(tostring(mech.Range))
local t = mw.text.trim(tostring(mech.Range))
if t ~= "" and not isNoneLike(t) then
if t ~= "" and not isNoneLike(t) then
Line 1,276: Line 1,276:
end
end


-- Area: Size (Number), none/missing => —
-- Area
local areaVal = formatAreaSize(mech.Area)
local areaVal = formatAreaSize(mech.Area, maxLevel, level)


-- Timings: 0 => —
-- Timings
local castSeries = seriesFromValuePair(bt["Cast Time"], maxLevel)
local castVal = displayFromSeries(seriesFromValuePair(bt["Cast Time"], maxLevel), level)
local cdSeries   = seriesFromValuePair(bt["Cooldown"],  maxLevel)
local cdVal   = displayFromSeries(seriesFromValuePair(bt["Cooldown"],  maxLevel), level)
local durSeries = seriesFromValuePair(bt["Duration"],  maxLevel)
local durVal = displayFromSeries(seriesFromValuePair(bt["Duration"],  maxLevel), level)


-- Promote status duration if needed
-- Promote status duration if needed
if (durSeries == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then
if (durVal == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then
durSeries = seriesFromValuePair(promo.durationBlock, maxLevel)
durVal = displayFromSeries(seriesFromValuePair(promo.durationBlock, maxLevel), level)
end
end


local castVal = displayFromSeries(castSeries, level)
-- Cost: MP + HP
local cdVal  = displayFromSeries(cdSeries, level)
local durVal  = displayFromSeries(durSeries, level)
 
-- Cost: build combined series, and never show "0 HP"
local function labeledSeries(block, label)
local function labeledSeries(block, label)
local s = seriesFromValuePair(block, maxLevel)
local s = seriesFromValuePair(block, maxLevel)
Line 1,332: Line 1,328:
local grid = mw.html.create("div")
local grid = mw.html.create("div")
grid:addClass("sv-m4-grid")
grid:addClass("sv-m4-grid")
grid:addClass("sv-compact-root")


local function addCell(label, val)
local function addCell(label, val)
Line 1,346: Line 1,343:
addCell("Duration",  durVal)
addCell("Duration",  durVal)


return moduleBox(4, "module-quick-stats", tostring(grid), false)
return {
inner = tostring(grid),
classes = "module-quick-stats",
}
end
end


local function computeDurationPromotion(rec, maxLevel)
-- PLUGIN: SpecialMechanics (Hero Module Slot 3)
-- Only:
-- Shows:
-- - non-damaging skills
--  - Flags (deduped)
-- - AND missing/blank Duration in Basic Timings
--  - Special mechanics (mech.Effects)
-- - AND a Status Application has a Duration block
-- NOTE: Combo lives in SkillType (Hero Bar Slot 2).
-- Then: promote that status duration into Module 4 and suppress it in Applies row.
function PLUGINS.SpecialMechanics(rec, ctx)
if type(rec) ~= "table" then return nil end
local level = ctx.level or 1
if skillHasAnyDamage(rec, maxLevel) then return nil end
local maxLevel = ctx.maxLevel or 1
 
local mech    = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
local effects = (type(mech.Effects) == "table") and mech.Effects or nil
local mods    = (type(rec.Modifiers) == "table") and rec.Modifiers or nil
 
------------------------------------------------------------------
-- Hits guard (we want Hits ONLY in SkillType)
------------------------------------------------------------------
local function isHitsKey(name)
if not name then return false end
local k = mw.ustring.lower(mw.text.trim(tostring(name)))
return (
k == "hit" or
k == "hits" or
k == "hit count" or
k == "hits count" or
k == "hitcount" or
k == "hitscount"
)
end
 
------------------------------------------------------------------
-- Flags (flat, de-duped)
------------------------------------------------------------------
local flagSet = {}
 
local denyFlags = {
["self centered"] = true,
["self-centred"] = true,
["bond"] = true,
["combo"] = true,
        ["hybrid"] = true,
 
-- hits variants
["hit"] = true,
["hits"] = true,
["hit count"] = true,
["hits count"] = true,
["hitcount"] = true,
["hitscount"] = true,
}
 
local function allowFlag(name)
if not name then return false end
local k = mw.ustring.lower(mw.text.trim(tostring(name)))
if k == "" then return false end
if denyFlags[k] then return false end
return true
end


local mech = (type(rec.Mechanics) == "table") and rec.Mechanics or {}
local function addFlags(sub)
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
if type(sub) ~= "table" then return end
local durS = seriesFromValuePair(bt["Duration"], maxLevel)
for k, v in pairs(sub) do
if v and allowFlag(k) then
flagSet[tostring(k)] = true
end
end
end


if durS ~= nil then
if mods then
-- Duration exists (even if some entries are —), so we don’t promote.
addFlags(mods["Movement Modifiers"])
return nil
addFlags(mods["Combat Modifiers"])
addFlags(mods["Special Modifiers"])
for k, v in pairs(mods) do
if type(v) == "boolean" and v and allowFlag(k) then
flagSet[tostring(k)] = true
end
end
end
end


local apps = rec["Status Applications"]
local flags = {}
if type(apps) ~= "table" then return nil end
for k, _ in pairs(flagSet) do table.insert(flags, k) end
table.sort(flags)
 
------------------------------------------------------------------
-- Special mechanics (name => value)
------------------------------------------------------------------
local mechItems = {}
 
if effects then
local keys = {}
for k, _ in pairs(effects) do table.insert(keys, k) end
table.sort(keys)
 
for _, name in ipairs(keys) do
-- Skip Hits completely (it belongs in SkillType)
if not isHitsKey(name) then
local block = effects[name]
if type(block) == "table" then
-- Also skip if the block's Type is "Hits" (some data may encode it that way)
if not isHitsKey(block.Type) then
local disp = displayFromSeries(seriesFromValuePair(block, maxLevel), level)
local t = trim(block.Type)
 
local value = disp
 
-- If Type exists and is distinct, prefix it.
if t and not isNoneLike(t) and mw.ustring.lower(t) ~= mw.ustring.lower(tostring(name)) then
if value then
value = mw.text.nowiki(t) .. ": " .. value
else
value = mw.text.nowiki(t)
end
end


for idx, app in ipairs(apps) do
if value then
if type(app) == "table" and type(app.Duration) == "table" then
table.insert(mechItems, { label = tostring(name), value = value })
local s = seriesFromValuePair(app.Duration, maxLevel)
end
if s then
for _, v in ipairs(s) do
if v ~= "—" then
return {
durationBlock = app.Duration,
suppressDurationIndex = idx,
}
end
end
end
end
Line 1,386: Line 1,471:
end
end


return nil
local hasFlags = (#flags > 0)
local hasMech  = (#mechItems > 0)
 
if (not hasFlags) and (not hasMech) then
local root = mw.html.create("div")
root:addClass("sv-sm-root")
root:addClass("sv-compact-root")
root:tag("div"):addClass("sv-sm-empty"):wikitext("No Special Mechanics")
 
return {
inner = tostring(root),
classes = "module-special-mechanics",
}
end
 
local count = 0
if hasFlags then count = count + 1 end
if hasMech  then count = count + 1 end
 
local root = mw.html.create("div")
root:addClass("sv-sm-root")
root:addClass("sv-compact-root")
 
local layout = root:tag("div"):addClass("sv-sm-layout")
layout:addClass("sv-sm-count-" .. tostring(count))
 
-- Column 1: Flags
if hasFlags then
local fcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-flags")
for _, f in ipairs(flags) do
fcol:tag("div"):addClass("sv-sm-flag"):wikitext(mw.text.nowiki(f))
end
end
 
-- Column 2: Special Mechanics (stacked)
if hasMech then
local mcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-mech")
for _, it in ipairs(mechItems) do
local one = mcol:tag("div"):addClass("sv-sm-mech")
one:tag("div"):addClass("sv-sm-label"):wikitext(mw.text.nowiki(it.label))
one:tag("div"):addClass("sv-sm-value"):wikitext(it.value or "—")
end
end
 
return {
inner = tostring(root),
classes = "module-special-mechanics",
}
end
 
-- PLUGIN: LevelSelector (Hero Module Slot 4) - JS level slider.
function PLUGINS.LevelSelector(rec, ctx)
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1
 
local inner = mw.html.create("div")
inner:addClass("sv-level-ui")
 
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")
 
if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
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")
else
inner:addClass("sv-level-ui-single")
slider:addClass("sv-level-slider-single")
end
 
return {
inner = tostring(inner),
classes = "module-level-selector",
}
end
end


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


local nonDamaging = false
-- normalizeResult: normalize plugin return values into {inner, classes}.
do
local function normalizeResult(res)
local dmgVal = nil
if res == nil then return nil end
if type(rec.Type) == "table" then
if type(res) == "string" then
dmgVal = rec.Type.Damage or rec.Type["Damage Type"]
return { inner = res, classes = nil }
if type(dmgVal) == "table" then
end
dmgVal = dmgVal.Name or dmgVal.ID or dmgVal.Value
if type(res) == "table" then
end
local inner = res.inner
if type(inner) ~= "string" then
inner = (inner ~= nil) and tostring(inner) or ""
end
end
nonDamaging = isNoneLike(dmgVal) or (not skillHasAnyDamage(rec, maxLevel))
return { inner = inner, classes = res.classes }
end
end
return { inner = tostring(res), classes = nil }
end


local promo = computeDurationPromotion(rec, maxLevel)
-- safeCallPlugin: pcall wrapper to prevent infobox failure on plugin errors.
local function safeCallPlugin(name, rec, ctx)
local fn = PLUGINS[name]
if type(fn) ~= "function" then
return nil
end
local ok, out = pcall(fn, rec, ctx)
if not ok then
return nil
end
return normalizeResult(out)
end


grid:wikitext(buildModuleLevelSelector(level, maxLevel))
-- renderHeroBarSlot: render a hero-bar slot by plugin assignment.
grid:wikitext(buildModuleSkillType(rec.Type or {}, nonDamaging))
local function renderHeroBarSlot(slotIndex, rec, ctx)
local pluginName = HERO_BAR_SLOT_ASSIGNMENT[slotIndex]
if not pluginName then
return heroBarBox(slotIndex, nil, "", true)
end


local m3 = buildModuleSkillSource(rec, level, maxLevel)
local res = safeCallPlugin(pluginName, rec, ctx)
grid:wikitext(m3 or buildEmptyModule(3))
if not res or not res.inner or res.inner == "" then
return heroBarBox(slotIndex, nil, "", true)
end


grid:wikitext(buildModuleQuickStats(rec, level, maxLevel, promo))
return heroBarBox(slotIndex, res.classes, res.inner, false)
end


return tostring(grid), promo
-- renderModuleSlot: render a hero-module slot by plugin assignment.
local function renderModuleSlot(slotIndex, rec, ctx)
local pluginName = HERO_MODULE_SLOT_ASSIGNMENT[slotIndex]
if not pluginName then
return moduleBox(slotIndex, nil, "", true)
end
 
local res = safeCallPlugin(pluginName, rec, ctx)
if not res or not res.inner or res.inner == "" then
return moduleBox(slotIndex, nil, "", true)
end
 
return moduleBox(slotIndex, res.classes, res.inner, false)
end
end


----------------------------------------------------------------------
-- UI builders
----------------------------------------------------------------------
-- buildHeroBarUI: build the top hero bar (2 slots).
local function buildHeroBarUI(rec, ctx)
local bar = mw.html.create("div")
bar:addClass("hero-bar-grid")
bar:wikitext(renderHeroBarSlot(1, rec, ctx))
bar:wikitext(renderHeroBarSlot(2, rec, ctx))
return tostring(bar)
end
-- buildHeroModulesUI: build the 2x2 module grid row (4 slots).
local function buildHeroModulesUI(rec, ctx)
local grid = mw.html.create("div")
grid:addClass("hero-modules-grid")
for slot = 1, 4 do
grid:wikitext(renderModuleSlot(slot, rec, ctx))
end
return tostring(grid)
end
-- addHeroModulesRow: add the hero-modules row into the infobox table.
local function addHeroModulesRow(tbl, modulesUI)
local function addHeroModulesRow(tbl, modulesUI)
if not modulesUI or modulesUI == "" then
if not modulesUI or modulesUI == "" then
Line 1,436: Line 1,659:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- buildInfobox: render a single skill infobox.
local function buildInfobox(rec, opts)
local function buildInfobox(rec, opts)
opts = opts or {}
opts = opts or {}
Line 1,443: Line 1,667:
if maxLevel < 1 then maxLevel = 1 end
if maxLevel < 1 then maxLevel = 1 end
local level = clamp(maxLevel, 1, maxLevel)
local level = clamp(maxLevel, 1, maxLevel)
local ctx = {
maxLevel = maxLevel,
level = level,
nonDamaging = false,
promo = nil,
}
-- Non-damaging hides Damage/Element/Hits in SkillType
do
local dmgVal = nil
if type(rec.Type) == "table" then
dmgVal = rec.Type.Damage or rec.Type["Damage Type"]
if type(dmgVal) == "table" then
dmgVal = dmgVal.Name or dmgVal.ID or dmgVal.Value
end
end
ctx.nonDamaging = isNoneLike(dmgVal) or (not skillHasAnyDamage(rec, maxLevel))
end
ctx.promo = computeDurationPromotion(rec, maxLevel)


local root = mw.html.create("table")
local root = mw.html.create("table")
Line 1,459: Line 1,704:
end
end


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 desc  = rec.Description or ""


-- Hero Title Bar
local heroRow = root:tag("tr")
local heroRow = root:tag("tr")
heroRow:addClass("spiritvale-infobox-main")
heroRow:addClass("spiritvale-infobox-main")
Line 1,471: Line 1,715:
heroCell:attr("colspan", 2)
heroCell:attr("colspan", 2)
heroCell:addClass("sv-hero-title-cell")
heroCell:addClass("sv-hero-title-cell")
heroCell:wikitext(buildHeroBarUI(rec, ctx))


local heroInner = heroCell:tag("div")
-- Description Bar
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)
 
if desc ~= "" then
if desc ~= "" then
local descRow = root:tag("tr")
local descRow = root:tag("tr")
Line 1,501: Line 1,736:
end
end


local modulesUI, promo = buildHeroModulesUI(rec, level, maxLevel)
-- Modules row
addHeroModulesRow(root, modulesUI)
addHeroModulesRow(root, buildHeroModulesUI(rec, ctx))


-- Users (hide on direct skill page)
if showUsers then
if showUsers then
local users = rec.Users or {}
local users = rec.Users or {}
Line 1,512: Line 1,748:
end
end


-- Requirements
local req = rec.Requirements or {}
local req = rec.Requirements or {}
local hasReq =
local hasReq =
Line 1,537: Line 1,774:
end
end


-- Mechanics (keep small extras only)
local mech = rec.Mechanics or {}
local mech = rec.Mechanics or {}
if next(mech) ~= nil then
if next(mech) ~= nil then
-- Range / Area / Timing / Resource Cost rows are removed:
-- They’re now covered by Module 4.
if mech["Autocast Multiplier"] ~= nil then
if mech["Autocast Multiplier"] ~= nil then
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
end
end
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
end


-- Source + Scaling are displayed in Module 3 now, so we do NOT repeat them as body rows.
-- Legacy damage breakdown (only when Source absent)
-- Keep the detailed legacy damage breakdown rows only (Main/Flat/Reflect/Healing).
if type(rec.Source) ~= "table" then
if type(rec.Source) ~= "table" then
local dmg = rec.Damage or {}
local dmg = rec.Damage or {}
Line 1,567: Line 1,799:
end
end


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


addRow(root, "Main Damage",   formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main Damage")
-- Status rows
addRow(root, "Flat Damage",    formatDamageList(flatList, maxLevel, level, false), "sv-row-source", "Damage.Flat Damage")
local function formatStatusApplications(list, suppressDurationIndex)
addRow(root, "Reflect Damage", formatDamageList(reflList, maxLevel, level, false), "sv-row-source", "Damage.Reflect Damage")
if type(list) ~= "table" or #list == 0 then return nil end
addRow(root, "Healing",        formatDamageList(healOnly, maxLevel, level, false), "sv-row-source", "Damage.Healing")
 
local parts = {}
for idx, 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 idx ~= suppressDurationIndex and type(s.Duration) == "table" then
local t = valuePairDynamicValueOnly(s.Duration, maxLevel, level)
if t then table.insert(detail, "Duration: " .. t) end
end
 
if type(s.Chance) == "table" then
local t = valuePairDynamicValueOnly(s.Chance, maxLevel, level)
if t then table.insert(detail, "Chance: " .. t) end
end
 
if #detail > 0 then
seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
end
 
table.insert(parts, seg)
end
end
end
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
end


local modsText = formatModifiers(rec.Modifiers)
local function formatStatusRemoval(list)
if modsText then
if type(list) ~= "table" or #list == 0 then return nil end
addRow(root, "Flags", modsText, "sv-row-meta", "Modifiers")
 
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 = valuePairRawText(r)
amt = amt and mw.text.nowiki(amt) or nil
 
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
end


local suppressIdx = (type(promo) == "table") and promo.suppressDurationIndex or nil
local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil
local statusApps = formatStatusApplications(rec["Status Applications"], maxLevel, level, suppressIdx)
local statusApps = formatStatusApplications(rec["Status Applications"], suppressIdx)
local statusRem  = formatStatusRemoval(rec["Status Removal"], maxLevel, level)
local statusRem  = formatStatusRemoval(rec["Status Removal"])
if statusApps or statusRem then
if statusApps or statusRem then
addRow(root, "Applies", statusApps, "sv-row-status", "Status Applications")
addRow(root, "Applies", statusApps, "sv-row-status", "Status Applications")
addRow(root, "Removes", statusRem,  "sv-row-status", "Status Removal")
addRow(root, "Removes", statusRem,  "sv-row-status", "Status Removal")
end
-- Events
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
end


Line 1,595: Line 1,897:
end
end


-- 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 />"), "sv-row-meta", "Notes")
addRow(root, "Notes", table.concat(rec.Notes, "<br />"), "sv-row-meta", "Notes")
Line 1,631: Line 1,934:
end
end


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


for _, rec in ipairs(matches) do
for _, rec in ipairs(matches) do
local item = root:tag("div"):addClass("sv-skill-item")
local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"
item:wikitext(buildInfobox(rec, { showUsers = false, inList = true }))
 
-- List mode: emit a raw H3 heading before each standalone card so TOC/anchors work.
table.insert(out, string.format("=== %s ===", title))
 
-- List mode cards are independent (no single wrapper container).
table.insert(out, buildInfobox(rec, { showUsers = false, inList = true }))
end
end


return tostring(root)
return table.concat(out, "\n")
end
end