Module:GameSkills: Difference between revisions
From SpiritVale Wiki
More actions
No edit summary |
No edit summary |
||
| (20 intermediate revisions by the same user not shown) | |||
| Line 3: | Line 3: | ||
-- Phase 6.5+ (Plug-in Slot Architecture) | -- Phase 6.5+ (Plug-in Slot Architecture) | ||
-- | -- | ||
-- | -- Layout: | ||
-- 1 | -- Row 1: Slot 1 + Slot 2 (Icon + SkillType) | ||
-- 2) | -- Row 2: Slot 3 + Slot 4 (Description + Placeholder) | ||
-- | -- Row 3: Slot 5 + Slot 6 (SourceType + QuickStats) | ||
-- Row 4: Slot 7 + Slot 8 (SpecialMechanics + LevelSelector) | |||
-- | -- | ||
-- Requires Common.js: | -- Requires Common.js: | ||
| Line 22: | Line 23: | ||
local skillsCache | local skillsCache | ||
local eventsCache | |||
-- getSkills: lazy-load + cache skill dataset from GameData. | |||
local function getSkills() | local function getSkills() | ||
if not skillsCache then | |||
skillsCache = GameData.loadSkills() | |||
end | |||
return skillsCache | |||
end | |||
local function getEvents() | |||
if eventsCache == nil then | |||
if type(GameData.loadEvents) == "function" then | |||
eventsCache = GameData.loadEvents() | |||
else | |||
eventsCache = false | |||
end | |||
end | |||
return eventsCache | |||
end | end | ||
| Line 34: | Line 49: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- 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 39: | Line 55: | ||
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 47: | Line 64: | ||
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 60: | Line 78: | ||
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 69: | Line 88: | ||
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 84: | Line 104: | ||
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 | |||
return nil | |||
end | |||
return table.concat(list, sep or ", ") | |||
end | |||
local function resolveDisplayName(v, kind) | |||
if v == nil then return nil end | |||
local function firstString(keys, source) | |||
for _, key in ipairs(keys) do | |||
local candidate = source[key] | |||
if type(candidate) == "string" and candidate ~= "" then | |||
return candidate | |||
end | |||
end | |||
return nil | |||
end | |||
if type(v) == "table" then | |||
local primaryKeys = { "External Name", "Display Name", "Name" } | |||
local extendedKeys = { "Skill External Name", "Status External Name" } | |||
local internalKeys = { "Internal Name", "Internal ID", "ID", "InternalID", "Skill Internal Name", "InternalID" } | |||
return firstString(primaryKeys, v) | |||
or firstString(extendedKeys, v) | |||
or firstString(internalKeys, v) | |||
end | |||
if type(v) == "string" then | |||
if kind == "event" then | |||
local events = getEvents() | |||
if events and events.byId and events.byId[v] then | |||
local mapped = resolveDisplayName(events.byId[v], "event") | |||
if mapped then | |||
return mapped | |||
end | |||
end | |||
end | |||
return v | |||
end | |||
return tostring(v) | |||
end | |||
local function resolveEventName(v) | |||
local resolved = resolveDisplayName(v, "event") | |||
if type(resolved) == "string" then | |||
return resolved | |||
end | |||
return (resolved ~= nil) and tostring(resolved) or nil | |||
end | |||
local function resolveSkillNameFromEvent(ev) | |||
if type(ev) ~= "table" then | |||
return resolveDisplayName(ev, "skill") or "Unknown skill" | |||
end | |||
local displayKeys = { | |||
"Skill External Name", | |||
"External Name", | |||
"Display Name", | |||
"Name", | |||
"Skill Name", | |||
} | |||
for _, key in ipairs(displayKeys) do | |||
local candidate = resolveDisplayName(ev[key], "skill") | |||
if candidate then | |||
return candidate | |||
end | |||
end | |||
local internalKeys = { | |||
"Skill Internal Name", | |||
"Skill ID", | |||
"Internal Name", | |||
"Internal ID", | |||
"ID", | |||
} | |||
for _, key in ipairs(internalKeys) do | |||
local candidate = ev[key] | |||
if type(candidate) == "string" and candidate ~= "" then | |||
return candidate | |||
end | |||
end | |||
return "Unknown skill" | |||
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 99: | Line 207: | ||
end | end | ||
-- 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 113: | Line 222: | ||
end | end | ||
-- { Value, Unit } or scalar | -- formatUnitValue: format {Value, Unit} blocks (or scalar) for display. | ||
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 141: | Line 250: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- 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 156: | Line 266: | ||
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 169: | Line 280: | ||
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 return false end | if v == nil then return false end | ||
| Line 183: | Line 295: | ||
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 201: | Line 314: | ||
end | end | ||
-- valuePairRawText: render Base/Per Level blocks into readable text (fallback). | |||
local function valuePairRawText(block) | local function valuePairRawText(block) | ||
if type(block) ~= "table" then | if type(block) ~= "table" then | ||
| Line 234: | Line 348: | ||
end | end | ||
-- 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 269: | Line 384: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- getSkillById: locate a skill by internal ID. | |||
local function getSkillById(id) | local function getSkillById(id) | ||
id = trim(id) | id = trim(id) | ||
| Line 276: | Line 392: | ||
end | end | ||
-- findSkillByName: locate a skill by external/display name. | |||
local function findSkillByName(name) | local function findSkillByName(name) | ||
name = trim(name) | name = trim(name) | ||
| Line 302: | Line 419: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- 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 321: | Line 439: | ||
end | end | ||
-- 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 372: | Line 491: | ||
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 398: | Line 518: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- 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 429: | Line 550: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- 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 453: | Line 575: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
local | local HERO_SLOT_ASSIGNMENT = { | ||
[1] = "IconName", | [1] = "IconName", | ||
[2] = " | [2] = "Description", | ||
[3] = "LevelSelector", | |||
[4] = "SkillType", | |||
[5] = "SourceType", | |||
[ | [6] = "QuickStats", | ||
[ | [7] = "SpecialMechanics", | ||
[ | [8] = "Placeholder", | ||
[ | |||
} | } | ||
| Line 469: | Line 590: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
local function | -- slotBox: standardized wrapper for all hero card slots. | ||
local function slotBox(slot, extraClasses, innerHtml, opts) | |||
opts = opts or {} | |||
local box = mw.html.create("div") | local box = mw.html.create("div") | ||
box:addClass(" | box:addClass("sv-slot") | ||
box:addClass(" | box:addClass("sv-slot--" .. tostring(slot)) | ||
box:attr("data-hero- | box:attr("data-hero-slot", tostring(slot)) | ||
if opts.isFull then | |||
box:addClass("sv-slot--full") | |||
end | end | ||
if extraClasses then | if extraClasses then | ||
| Line 514: | Line 611: | ||
end | end | ||
if isEmpty then | if opts.isEmpty then | ||
box:addClass(" | box:addClass("sv-slot--empty") | ||
end | end | ||
local body = box:tag("div"):addClass(" | local body = box:tag("div"):addClass("sv-slot__body") | ||
if innerHtml and innerHtml ~= "" then | if innerHtml and innerHtml ~= "" then | ||
body:wikitext(innerHtml) | body:wikitext(innerHtml) | ||
| Line 530: | Line 627: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- 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 562: | Line 660: | ||
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 573: | Line 672: | ||
end | end | ||
-- legacyPercentAtLevel: compute “Base% + PerLevel%*level” for legacy entries. | |||
local function legacyPercentAtLevel(entry, level) | local function legacyPercentAtLevel(entry, level) | ||
if type(entry) ~= "table" then | if type(entry) ~= "table" then | ||
| Line 598: | Line 698: | ||
end | end | ||
-- seriesFromValuePair: normalize Base/Per Level blocks into a level-indexed series. | |||
local function seriesFromValuePair(block, maxLevel) | local function seriesFromValuePair(block, maxLevel) | ||
if type(block) ~= "table" then | if type(block) ~= "table" then | ||
| Line 678: | Line 779: | ||
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 700: | Line 802: | ||
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 | -- Helper: pull a number from scalar/unit/valuepair-ish things. | ||
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 | 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 | return nil | ||
end | end | ||
if mw.ustring. | |||
return | -- 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 | end | ||
if | |||
return nil | |||
end | |||
-- 1) Read Area Size label/name | |||
local rawSize = area["Area Size"] | |||
if rawSize == nil then | |||
return nil | |||
end | |||
local sizeName = nil | |||
if type(rawSize) == "table" then | |||
sizeName = rawSize.Name or rawSize.ID or rawSize.Value | |||
elseif type(rawSize) == "string" then | |||
sizeName = rawSize | |||
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 | ||
end | end | ||
if num | if not num or num == 0 then | ||
num = | |||
extractNumber(area["Area Value"]) or | |||
extractNumber(area["Area Size Value"]) or | |||
extractNumber(area["Area Number"]) or | |||
extractNumber(area["Area Radius"]) | |||
end | end | ||
return | -- 3) Render | ||
-- If size already contains parentheses, assume it already includes the numeric. | |||
if mw.ustring.find(sizeName, "%(") then | |||
return mw.text.nowiki(sizeName) | |||
end | |||
if num and num ~= 0 then | |||
return mw.text.nowiki(string.format("%s (%s)", sizeName, fmtNum(num))) | |||
end | |||
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) | ||
if type(rec.Source) == "table" then | if type(rec.Source) == "table" then | ||
| Line 772: | Line 923: | ||
end | end | ||
-- computeDurationPromotion: promote status-duration into QuickStats when a skill is non-damaging. | |||
local function computeDurationPromotion(rec, maxLevel) | local function computeDurationPromotion(rec, maxLevel) | ||
if type(rec) ~= "table" then return nil end | if type(rec) ~= "table" then return nil end | ||
| Line 818: | Line 970: | ||
local PLUGINS = {} | local PLUGINS = {} | ||
-- Hero Bar Slot 1 | -- PLUGIN: IconName (Hero Bar Slot 1) - icon + name. | ||
function PLUGINS.IconName(rec, ctx) | function PLUGINS.IconName(rec, ctx) | ||
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" | ||
local | local notesList = {} | ||
if type(rec.Notes) == "table" then | |||
for _, note in ipairs(rec.Notes) do | |||
local n = trim(note) | |||
if n then | |||
table.insert(notesList, mw.text.nowiki(n)) | |||
end | |||
end | |||
elseif type(rec.Notes) == "string" then | |||
local n = trim(rec.Notes) | |||
if n then | |||
notesList = { mw.text.nowiki(n) } | |||
end | |||
end | |||
local req = rec.Requirements or {} | |||
local reqSkillsRaw = (type(req["Required Skills"]) == "table") and req["Required Skills"] or {} | |||
local reqWeaponsRaw = (type(req["Required Weapons"]) == "table") and req["Required Weapons"] or {} | |||
local reqStancesRaw = (type(req["Required Stances"]) == "table") and req["Required Stances"] or {} | |||
if | local reqSkills = {} | ||
for _, rs in ipairs(reqSkillsRaw) do | |||
if type(rs) == "table" then | |||
local nameReq = rs["Skill External Name"] or rs["Skill Internal Name"] or "Unknown" | |||
local lvlReq = rs["Required Level"] | |||
if lvlReq then | |||
table.insert(reqSkills, string.format("%s (Lv.%s)", mw.text.nowiki(nameReq), mw.text.nowiki(tostring(lvlReq)))) | |||
else | |||
table.insert(reqSkills, mw.text.nowiki(nameReq)) | |||
end | |||
end | |||
end | |||
local reqWeapons = {} | |||
for _, w in ipairs(reqWeaponsRaw) do | |||
local wn = trim(w) | |||
if wn then table.insert(reqWeapons, mw.text.nowiki(wn)) end | |||
end | |||
local reqStances = {} | |||
for _, s in ipairs(reqStancesRaw) do | |||
local sn = trim(s) | |||
if sn then table.insert(reqStances, mw.text.nowiki(sn)) end | |||
end | end | ||
local hasNotes = (#notesList > 0) | |||
local hasReq = (#reqSkills > 0) or (#reqWeapons > 0) or (#reqStances > 0) | |||
local wrap = mw.html.create("div") | local wrap = mw.html.create("div") | ||
wrap:addClass("sv-herobar- | wrap:addClass("sv-herobar-1-wrap") | ||
wrap:addClass("sv-tip-scope") | |||
local iconBox = wrap:tag("div") | |||
iconBox:addClass("sv-herobar-icon") | |||
local | if icon and icon ~= "" then | ||
iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon)) | |||
end | |||
local textBox = wrap:tag("div") | |||
textBox:addClass("sv-herobar-text") | |||
local titleRow = textBox:tag("div") | |||
titleRow:addClass("sv-herobar-title-row") | |||
local | local titleBox = titleRow:tag("div") | ||
titleBox:addClass("spiritvale-infobox-title") | |||
titleBox:wikitext(title) | |||
if | if hasNotes then | ||
local notesBtn = mw.html.create("span") | |||
notesBtn:addClass("sv-tip-btn sv-tip-btn--notes") | |||
notesBtn:attr("role", "button") | |||
notesBtn:attr("tabindex", "0") | |||
notesBtn:attr("data-sv-tip", "notes") | |||
notesBtn:attr("aria-label", "Notes") | |||
notesBtn:attr("aria-expanded", "false") | |||
notesBtn:tag("span"):addClass("sv-ico sv-ico--info"):attr("aria-hidden", "true"):wikitext("i") | |||
titleRow:node(notesBtn) | |||
end | end | ||
if hasReq then | |||
local pillRow = wrap:tag("div") | |||
pillRow:addClass("sv-pill-row") | |||
pillRow:addClass("sv-pill-row--req") | |||
end | local pill = pillRow:tag("span") | ||
pill:addClass("sv-pill sv-pill--req sv-tip-btn") | |||
pill:attr("role", "button") | |||
pill:attr("tabindex", "0") | |||
pill:attr("data-sv-tip", "req") | |||
pill:attr("aria-label", "Requirements") | |||
pill:attr("aria-expanded", "false") | |||
pill:wikitext("Requirements") | |||
end | |||
-- | if hasNotes then | ||
local notesContent = wrap:tag("div") | |||
notesContent:addClass("sv-tip-content") | |||
notesContent:attr("data-sv-tip-content", "notes") | |||
notesContent:tag("div"):addClass("sv-tip-title"):wikitext("Notes") | |||
notesContent:tag("div"):wikitext(table.concat(notesList, "<br />")) | |||
end | |||
if hasReq then | |||
local reqContent = wrap:tag("div") | |||
reqContent:addClass("sv-tip-content") | |||
reqContent:attr("data-sv-tip-content", "req") | |||
if #reqSkills > 0 then | |||
local section = reqContent:tag("div") | |||
section:addClass("sv-tip-section") | |||
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Skills") | |||
section:tag("div"):wikitext(table.concat(reqSkills, "<br />")) | |||
end | |||
if #reqWeapons > 0 then | |||
local section = reqContent:tag("div") | |||
section:addClass("sv-tip-section") | |||
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Weapons") | |||
section:tag("div"):wikitext(table.concat(reqWeapons, ", ")) | |||
end | |||
if #reqStances > 0 then | |||
local section = reqContent:tag("div") | |||
section:addClass("sv-tip-section") | |||
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Stances") | |||
section:tag("div"):wikitext(table.concat(reqStances, ", ")) | |||
end | |||
end | |||
return { | |||
inner = tostring(wrap), | |||
classes = "module-icon-name", | |||
} | |||
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) | function PLUGINS.SkillType(rec, ctx) | ||
local typeBlock = (type(rec.Type) == "table") and rec.Type or {} | local typeBlock = (type(rec.Type) == "table") and rec.Type or {} | ||
local | 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) | local function valName(x) | ||
if x == nil then return nil end | if x == nil then return nil end | ||
| Line 896: | Line 1,137: | ||
if x.ID and x.ID ~= "" then return tostring(x.ID) end | if x.ID and x.ID ~= "" then return tostring(x.ID) end | ||
if x.Value ~= nil then return tostring(x.Value) end | if x.Value ~= nil then return tostring(x.Value) end | ||
end | |||
if type(x) == "number" then | |||
return tostring(x) | |||
end | end | ||
if type(x) == "string" and x ~= "" then | if type(x) == "string" and x ~= "" then | ||
| Line 903: | Line 1,147: | ||
end | end | ||
local | -- 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 | |||
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 | 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 | if not typ or isNoneLike(typ) then | ||
return nil | return nil | ||
end | end | ||
local | 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 | end | ||
return | if #details > 0 then | ||
return mw.text.nowiki(typ) .. " (" .. table.concat(details, ", ") .. ")" | |||
end | |||
return mw.text.nowiki(typ) | |||
end | 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 | ||
end | end | ||
-- Target + Cast | |||
local | local tgt = valName(typeBlock.Target or typeBlock["Target Type"]) | ||
local | local cst = valName(typeBlock.Cast or typeBlock["Cast Type"]) | ||
if ( | 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 | end | ||
local | -- 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: Description (Hero Slot 3) - primary description text. | |||
function PLUGINS.Description(rec) | |||
local desc = trim(rec.Description) | |||
if not desc then | |||
return nil | |||
end | |||
local body = mw.html.create("div") | |||
body:addClass("sv-description") | |||
body:wikitext(string.format("''%s''", desc)) | |||
return { | |||
inner = tostring(body), | |||
classes = "module-description", | |||
} | |||
end | |||
-- PLUGIN: Placeholder (Hero Slot 4) - reserved/blank. | |||
function PLUGINS.Placeholder() | |||
return nil | |||
end | |||
-- PLUGIN: SourceType (Hero Module Slot 1) - Modifier + Source + Scaling. | |||
function PLUGINS.SourceType(rec, ctx) | |||
function PLUGINS. | |||
local level = ctx.level or 1 | local level = ctx.level or 1 | ||
local maxLevel = ctx.maxLevel or 1 | local maxLevel = ctx.maxLevel or 1 | ||
local | local basisWord = nil | ||
local | local sourceKind = nil | ||
local | local sourceVal = nil | ||
local scaling = nil | |||
local function | -- 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 | end | ||
local | local series = {} | ||
for _, v in ipairs(per) do | |||
table.insert(series, formatUnitValue(v) or tostring(v)) | |||
end | end | ||
return dynSpan(series, level) | |||
end | end | ||
return valuePairDynamicValueOnly(src, maxLevel, level) | |||
end | 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 | 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 | 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 | |||
end | |||
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "") | |||
local extra = { "skill-source-module", "module-source-type" } | |||
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 | local wrap = mw.html.create("div") | ||
wrap:addClass("sv-source-grid") | |||
wrap:addClass("sv-compact-root") | |||
if | 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 | end | ||
local | 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") | |||
local | for _, line in ipairs(scalingLines) do | ||
for | 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 bt = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {} | |||
local rc = (type(mech["Resource Cost"]) == "table") and mech["Resource Cost"] or {} | |||
local function dash() return "—" end | |||
-- Range (0 => —) | |||
local rangeVal = nil | |||
if mech.Range ~= nil and not isNoneLike(mech.Range) then | |||
local n = toNum(mech.Range) | |||
if n ~= nil then | |||
if n ~= 0 then | |||
if | rangeVal = mw.text.nowiki(formatUnitValue(mech.Range) or tostring(mech.Range)) | ||
end | end | ||
else | |||
local t = mw.text.trim(tostring(mech.Range)) | |||
if t ~= "" and not isNoneLike(t) then | |||
rangeVal = mw.text.nowiki(t) | |||
end | end | ||
end | end | ||
end | end | ||
-- | -- Area | ||
local areaVal = formatAreaSize(mech.Area, maxLevel, level) | |||
-- Timings | |||
local castVal = displayFromSeries(seriesFromValuePair(bt["Cast Time"], maxLevel), level) | |||
local cdVal = displayFromSeries(seriesFromValuePair(bt["Cooldown"], maxLevel), level) | |||
local durVal = displayFromSeries(seriesFromValuePair(bt["Duration"], maxLevel), level) | |||
-- Promote status duration if needed | |||
if (durVal == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then | |||
durVal = displayFromSeries(seriesFromValuePair(promo.durationBlock, maxLevel), level) | |||
end | end | ||
local | -- Cost: MP + HP | ||
local function labeledSeries(block, label) | |||
local s = seriesFromValuePair(block, maxLevel) | |||
if not s then return nil end | |||
local any = false | |||
for i, v in ipairs(s) do | |||
if v ~= "—" then | |||
s[i] = tostring(v) .. " " .. label | |||
any = true | |||
else | |||
s[i] = "—" | |||
end | |||
end | |||
return any and s or nil | |||
end | |||
local mpS = labeledSeries(rc["Mana Cost"], "MP") | |||
local hpS = labeledSeries(rc["Health Cost"], "HP") | |||
local | |||
local | local costSeries = {} | ||
for lv = 1, maxLevel do | |||
local mp = mpS and mpS[lv] or "—" | |||
local hp = hpS and hpS[lv] or "—" | |||
if mp ~= "—" and hp ~= "—" then | |||
costSeries[lv] = mp .. " + " .. hp | |||
elseif mp ~= "—" then | |||
costSeries[lv] = mp | |||
elseif hp ~= "—" then | |||
costSeries[lv] = hp | |||
else | |||
costSeries[lv] = "—" | |||
end | end | ||
end | end | ||
local costVal = displayFromSeries(costSeries, level) | |||
local grid = mw.html.create("div") | |||
grid:addClass("sv-m4-grid") | |||
grid:addClass("sv-compact-root") | |||
local function addCell(label, val) | |||
local cell = grid:tag("div"):addClass("sv-m4-cell") | |||
cell:tag("div"):addClass("sv-m4-label"):wikitext(mw.text.nowiki(label)) | |||
cell:tag("div"):addClass("sv-m4-value"):wikitext(val or dash()) | |||
end | end | ||
addCell("Range", rangeVal) | |||
addCell("Area", areaVal) | |||
addCell("Cost", costVal) | |||
addCell("Cast Time", castVal) | |||
addCell("Cooldown", cdVal) | |||
addCell("Duration", durVal) | |||
return { | return { | ||
inner = tostring( | inner = tostring(grid), | ||
classes = "module- | classes = "module-quick-stats", | ||
} | } | ||
end | end | ||
-- PLUGIN: SpecialMechanics (Hero Module Slot 3) | |||
-- Shows: | |||
-- - Flags (deduped) | |||
-- - Special mechanics (mech.Effects) | |||
-- NOTE: Combo lives in SkillType (Hero Bar Slot 2). | |||
function PLUGINS.SpecialMechanics(rec, ctx) | |||
local level = ctx.level or 1 | |||
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 | |||
local function | ------------------------------------------------------------------ | ||
-- 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 | end | ||
------------------------------------------------------------------ | |||
local | -- 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 | local function allowFlag(name) | ||
if not name then return false end | |||
return | 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 | end | ||
local function addFlags(sub) | |||
end | if type(sub) ~= "table" then return end | ||
for k, v in pairs(sub) do | |||
if v and allowFlag(k) then | |||
flagSet[tostring(k)] = true | |||
end | |||
end | |||
end | end | ||
if mods then | |||
addFlags(mods["Movement Modifiers"]) | |||
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 flags = {} | |||
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 | 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 | |||
if value then | |||
table.insert(mechItems, { label = tostring(name), value = value }) | |||
end | |||
end | |||
end | end | ||
end | |||
end | |||
end | |||
local hasFlags = (#flags > 0) | |||
local hasMech = (#mechItems > 0) | |||
local | 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 | 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 | 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 | ||
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 | 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") | |||
local | |||
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 | end | ||
-- | return { | ||
inner = tostring(inner), | |||
classes = "module-level-selector", | |||
} | |||
if | end | ||
local | |||
---------------------------------------------------------------------- | |||
-- Generic slot renderers | |||
---------------------------------------------------------------------- | |||
-- normalizeResult: normalize plugin return values into {inner, classes}. | |||
local function normalizeResult(res) | |||
if res == nil then return nil end | |||
if type(res) == "string" then | |||
return { inner = res, classes = nil } | |||
end | |||
if type(res) == "table" then | |||
local inner = res.inner | |||
if type(inner) ~= "string" then | |||
if | inner = (inner ~= nil) and tostring(inner) or "" | ||
if type( | end | ||
local | return { inner = inner, classes = res.classes } | ||
end | |||
return { inner = tostring(res), classes = nil } | |||
end | |||
-- 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 | |||
-- isEmptySlotContent: true when a slot has no meaningful content. | |||
-- NOTE: JS placeholders (sv-dyn spans, slider markup) are considered content. | |||
local function isEmptySlotContent(inner) | |||
if inner == nil then return true end | |||
local raw = tostring(inner) | |||
-- Legacy damage breakdown (only when Source absent) | -- Guard rails for JS-injected regions. | ||
if type(rec.Source) ~= "table" then | for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do | ||
local dmg = rec.Damage or {} | if mw.ustring.find(raw, pat) then | ||
return false | |||
end | |||
end | |||
local trimmed = mw.text.trim(raw) | |||
if trimmed == "" or trimmed == "—" then | |||
return true | |||
end | |||
local withoutTags = mw.text.trim(mw.ustring.gsub(trimmed, "<[^>]+>", "")) | |||
return (withoutTags == "" or withoutTags == "—") | |||
end | |||
-- renderHeroSlot: render a standardized hero slot by plugin assignment. | |||
local function renderHeroSlot(slotIndex, rec, ctx) | |||
local pluginName = HERO_SLOT_ASSIGNMENT[slotIndex] | |||
if not pluginName then | |||
return nil | |||
end | |||
local res = safeCallPlugin(pluginName, rec, ctx) | |||
if not res or isEmptySlotContent(res.inner) then | |||
return nil | |||
end | |||
return { | |||
inner = res.inner, | |||
classes = res.classes, | |||
} | |||
end | |||
---------------------------------------------------------------------- | |||
-- UI builders | |||
---------------------------------------------------------------------- | |||
-- buildHeroSlotsUI: build the standardized 4-row slot grid (2 columns). | |||
local function buildHeroSlotsUI(rec, ctx) | |||
local grid = mw.html.create("div") | |||
grid:addClass("sv-slot-grid") | |||
local slots = {} | |||
for slot = 1, 8 do | |||
slots[slot] = renderHeroSlot(slot, rec, ctx) | |||
end | |||
local hasSlots = false | |||
for _, pair in ipairs({ { 1, 2 }, { 3, 4 }, { 5, 6 }, { 7, 8 } }) do | |||
local left = slots[pair[1]] | |||
local right = slots[pair[2]] | |||
if left or right then | |||
hasSlots = true | |||
if left and right then | |||
grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isEmpty = false })) | |||
grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isEmpty = false })) | |||
elseif left then | |||
grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isFull = true })) | |||
elseif right then | |||
grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isFull = true })) | |||
end | |||
end | |||
end | |||
if not hasSlots then | |||
return "" | |||
end | |||
return tostring(grid) | |||
end | |||
-- addHeroSlotsRow: add the standardized slot grid into the infobox table. | |||
local function addHeroSlotsRow(tbl, slotsUI) | |||
if not slotsUI or slotsUI == "" then | |||
return | |||
end | |||
local row = tbl:tag("tr") | |||
row:addClass("sv-slot-row") | |||
local cell = row:tag("td") | |||
cell:attr("colspan", 2) | |||
cell:addClass("sv-slot-cell") | |||
cell:wikitext(slotsUI) | |||
end | |||
---------------------------------------------------------------------- | |||
-- Infobox builder | |||
---------------------------------------------------------------------- | |||
-- buildInfobox: render a single skill infobox. | |||
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 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") | |||
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 | |||
local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID) | |||
if internalId then | |||
root:attr("data-skill-id", internalId) | |||
end | |||
-- Standardized slot grid | |||
addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx)) | |||
-- Users (hide on direct skill page) | |||
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") | |||
do | |||
local eventsList = {} | |||
if type(users.Events) == "table" then | |||
for _, ev in ipairs(users.Events) do | |||
local name = resolveEventName(ev) or ev | |||
if name ~= nil then | |||
table.insert(eventsList, mw.text.nowiki(tostring(name))) | |||
end | |||
end | |||
end | |||
addRow(root, "Events", listToText(eventsList), "sv-row-users", "Users.Events") | |||
end | |||
end | |||
-- Mechanics (keep small extras only) | |||
local mech = rec.Mechanics or {} | |||
if next(mech) ~= nil then | |||
if mech["Autocast Multiplier"] ~= nil then | |||
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier") | |||
end | |||
end | |||
-- Legacy damage breakdown (only when Source absent) | |||
if type(rec.Source) ~= "table" then | |||
local dmg = rec.Damage or {} | |||
if next(dmg) ~= nil then | if next(dmg) ~= nil then | ||
local main = dmg["Main Damage"] | local main = dmg["Main Damage"] | ||
| Line 1,591: | Line 2,002: | ||
end | end | ||
-- Status rows | -- Status rows | ||
local function formatStatusApplications(list, suppressDurationIndex) | local function formatStatusApplications(list, suppressDurationIndex) | ||
if type(list) ~= "table" or #list == 0 then return nil end | if type(list) ~= "table" or #list == 0 then return nil end | ||
| Line 1,663: | Line 2,074: | ||
end | 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 = resolveDisplayName(ev.Action, "event") or ev.Action or "On event" | |||
local name = resolveSkillNameFromEvent(ev) | |||
table.insert(parts, string.format("%s → %s", mw.text.nowiki(action), mw.text.nowiki(name))) | |||
end | |||
end | |||
return (#parts > 0) and table.concat(parts, "<br />") or nil | |||
end | |||
local eventsText = formatEvents(rec.Events) | local eventsText = formatEvents(rec.Events) | ||
| Line 1,682: | Line 2,093: | ||
end | end | ||
return tostring(root) | |||
end | end | ||
| Line 1,719: | Line 2,125: | ||
end | end | ||
local | local out = {} | ||
for _, rec in ipairs(matches) do | for _, rec in ipairs(matches) do | ||
local | local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill" | ||
-- 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 | return table.concat(out, "\n") | ||
end | end | ||