Module:GameSkills: Difference between revisions
From SpiritVale Wiki
More actions
No edit summary |
No edit summary |
||
| (28 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
-- Module:GameSkills | -- Module:GameSkills | ||
-- | -- | ||
-- | -- Phase 6.5+ (Plug-in Slot Architecture) | ||
- | |||
-- | -- | ||
-- | -- Layout: | ||
-- | -- 1) hero-title-bar (TOP BAR, 2 slots: herobar 1..2) | ||
-- | -- 2) hero-description-bar (description strip) | ||
-- | -- 3) hero-modules row (4 slots: hero-module-1..4) | ||
-- | -- | ||
-- Requires Common.js: | |||
-- - updates .sv-dyn spans via data-series | |||
-- - updates .sv-level-num + data-level on .sv-skill-card | |||
-- - binds to input.sv-level-range inside each card | |||
local GameData = require("Module:GameData") | local GameData = require("Module:GameData") | ||
| Line 22: | Line 18: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- | -- Data cache | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
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 34: | Line 31: | ||
end | end | ||
---------------------------------------------------------------------- | |||
-- Small utilities | |||
---------------------------------------------------------------------- | |||
-- 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() | ||
return (parent and parent.args) or frame.args | |||
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 71: | Line 47: | ||
end | end | ||
s = mw.text.trim(s) | s = mw.text.trim(s) | ||
return (s ~= "" and s) or nil | |||
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 90: | 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 | ||
return lo | return lo | ||
end | end | ||
if n < lo then | if n < lo then return lo end | ||
if n > hi then return hi end | |||
if n > hi then | |||
return n | return n | ||
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 118: | Line 90: | ||
end | end | ||
-- | -- listToText: join an array into a readable string. | ||
-- | local function listToText(list, sep) | ||
if type(list) ~= "table" or #list == 0 then | |||
return nil | |||
end | |||
return table.concat(list, sep or ", ") | |||
end | |||
-- isNoneLike: treat common "none" spellings as empty. | |||
local function isNoneLike(v) | |||
if v == nil then return true end | |||
local s = mw.text.trim(tostring(v)) | |||
if s == "" then return true end | |||
s = mw.ustring.lower(s) | |||
return (s == "none" or s == "no" or s == "n/a" or s == "na" or s == "null") | |||
end | |||
-- addRow: add a standard <tr><th>Label</th><td>Value</td></tr> row. | |||
local function addRow(tbl, label, value, rowClass, dataKey) | |||
if value == nil or value == "" then | |||
return | |||
end | |||
local row = tbl:tag("tr") | |||
row:addClass("sv-row") | |||
if rowClass then row:addClass(rowClass) end | |||
if dataKey then row:attr("data-field", dataKey) end | |||
row:tag("th"):wikitext(label):done() | |||
row:tag("td"):wikitext(value):done() | |||
end | |||
-- 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 140: | Line 143: | ||
end | end | ||
return (v ~= nil) and tostring(v) or nil | |||
end | end | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- Dynamic | -- Dynamic spans (JS-driven) | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- 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 165: | 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 178: | 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 | ||
if type(v) == "number" then return v ~= 0 end | |||
if type(v) == "number" then | |||
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 n ~= 0 | return n ~= 0 | ||
end | end | ||
| Line 198: | Line 195: | ||
end | end | ||
-- | -- isZeroish: aggressively treat common “zero” text forms as zero. | ||
local function isZeroish(v) | |||
-- | if v == nil then return true end | ||
local function | if type(v) == "number" then return v == 0 end | ||
if type(v) == "table" and v.Value ~= nil then | |||
return isZeroish(v.Value) | |||
end | |||
local s = mw.text.trim(tostring(v)) | |||
if s == "" 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 == "0m" or s == "0 m" then return true end | |||
if s == "0%" or s == "0 %" then return true end | |||
local n = tonumber((mw.ustring.gsub(s, "[^0-9%.%-]", ""))) | |||
return (n ~= nil and n == 0) | |||
end | |||
-- valuePairRawText: render Base/Per Level blocks into readable text (fallback). | |||
local function valuePairRawText(block) | |||
if type(block) ~= "table" then | if type(block) ~= "table" then | ||
return | return nil | ||
end | end | ||
| Line 209: | Line 223: | ||
local per = block["Per Level"] | local per = block["Per Level"] | ||
if type(per) == "table" then | if type(per) == "table" then | ||
if #per == 0 then | if #per == 0 then | ||
return formatUnitValue(base) | |||
end | end | ||
if isFlatList(per) then | if isFlatList(per) then | ||
return formatUnitValue(base) or tostring(per[1]) | |||
end | end | ||
local vals = {} | |||
local | |||
for _, v in ipairs(per) do | for _, v in ipairs(per) do | ||
table.insert( | table.insert(vals, formatUnitValue(v) or tostring(v)) | ||
end | end | ||
return (#vals > 0) and table.concat(vals, " / ") or nil | |||
end | end | ||
local baseText = formatUnitValue(base) | local baseText = formatUnitValue(base) | ||
local perText = formatUnitValue(per) | local perText = formatUnitValue(per) | ||
if baseText | if baseText and perText and isNonZeroScalar(per) then | ||
return string.format("%s (Per Level: %s)", baseText, perText) | |||
end | end | ||
return | return baseText or perText | ||
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 317: | Line 257: | ||
local per = block["Per Level"] | local per = block["Per Level"] | ||
if type(per) == "table" then | if type(per) == "table" then | ||
if #per == 0 then | if #per == 0 then | ||
| Line 325: | Line 264: | ||
if isFlatList(per) then | if isFlatList(per) then | ||
local one = formatUnitValue(per[1]) or tostring(per[1]) | local one = formatUnitValue(per[1]) or tostring(per[1]) | ||
local show = formatUnitValue(base) or one | local show = formatUnitValue(base) or one | ||
return show and mw.text.nowiki(show) or nil | return show and mw.text.nowiki(show) or nil | ||
| Line 341: | Line 280: | ||
end | end | ||
---------------------------------------------------------------------- | |||
-- Lookups | |||
---------------------------------------------------------------------- | |||
-- getSkillById: locate a skill by internal ID. | |||
local | local function getSkillById(id) | ||
id = trim(id) | |||
if not id then return nil end | |||
local dataset = getSkills() | |||
return (dataset.byId or {})[id] | |||
end | |||
-- findSkillByName: locate a skill by external/display name. | |||
local function findSkillByName(name) | |||
name = trim(name) | |||
if not name then return nil end | |||
local dataset = getSkills() | |||
local byName = dataset.byName or {} | |||
if byName[name] then | |||
if | return byName[name] | ||
return | |||
end | end | ||
for _, rec in ipairs(dataset.records or {}) do | |||
if type(rec) == "table" then | |||
if rec["External Name"] == name or rec.Name == name or rec["Display Name"] == name then | |||
return rec | |||
end | |||
end | |||
end | end | ||
return | return nil | ||
end | end | ||
---------------------------------------------------------------------- | |||
-- 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 386: | Line 339: | ||
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 394: | Line 346: | ||
local isHealing = (entry.Type == "Healing") | local isHealing = (entry.Type == "Healing") | ||
local basis = isHealing and "Healing" or basisLabel(entry) | local basis = isHealing and "Healing" or basisLabel(entry, false) | ||
local baseRaw = entry["Base %"] | local baseRaw = entry["Base %"] | ||
| Line 413: | Line 365: | ||
end | end | ||
local baseText | local baseText | ||
if baseIsPresent() then | if baseIsPresent() then | ||
baseText = (baseN ~= nil) and (fmtNum(baseN) .. "%") or (tostring(baseRaw) .. "%") | |||
end | end | ||
if perN == nil or perN == 0 or not maxLevel or maxLevel <= 0 then | if perN == nil or perN == 0 or not maxLevel or maxLevel <= 0 then | ||
return baseText and mw.text.nowiki(baseText .. " " .. basis) or nil | return baseText and mw.text.nowiki(baseText .. " " .. basis) or nil | ||
end | end | ||
local series = {} | local series = {} | ||
for lv = 1, maxLevel do | for lv = 1, maxLevel do | ||
| Line 445: | 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 464: | Line 411: | ||
end | end | ||
return (#parts > 0) and table.concat(parts, "<br />") or nil | |||
return | end | ||
---------------------------------------------------------------------- | |||
-- Users matching | |||
---------------------------------------------------------------------- | |||
-- skillMatchesUser: check if a skill is used by a specific class/monster/summon/event. | |||
local function skillMatchesUser(rec, userName) | |||
if type(rec) ~= "table" or not userName or userName == "" then | |||
return false | |||
end | |||
local users = rec.Users | |||
if type(users) ~= "table" then | |||
return false | |||
end | |||
local userLower = mw.ustring.lower(userName) | |||
local function listHas(list) | |||
if type(list) ~= "table" then | |||
return false | |||
end | |||
for _, v in ipairs(list) do | |||
if type(v) == "string" and mw.ustring.lower(v) == userLower then | |||
return true | |||
end | |||
end | |||
return false | |||
end | |||
return listHas(users.Classes) or listHas(users.Summons) or listHas(users.Monsters) or listHas(users.Events) | |||
end | |||
---------------------------------------------------------------------- | |||
-- Hide Users on direct skill pages | |||
---------------------------------------------------------------------- | |||
-- isDirectSkillPage: hide Users rows when viewing the skill page itself. | |||
local function isDirectSkillPage(rec) | |||
if type(rec) ~= "table" then | |||
return false | |||
end | |||
local pageTitle = mw.title.getCurrentTitle() | |||
local pageName = pageTitle and pageTitle.text or "" | |||
pageName = trim(pageName) | |||
if not pageName then | |||
return false | |||
end | |||
pageName = mw.ustring.lower(pageName) | |||
local ext = trim(rec["External Name"] or rec.Name or rec["Display Name"]) | |||
local internal = trim(rec["Internal Name"] or rec.InternalName or rec.InternalID) | |||
return (ext and mw.ustring.lower(ext) == pageName) or (internal and mw.ustring.lower(internal) == pageName) or false | |||
end | |||
---------------------------------------------------------------------- | |||
-- 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 | |||
---------------------------------------------------------------------- | |||
-- heroBarBox: wrapper for hero-bar slot modules. | |||
local function heroBarBox(slot, extraClasses, innerHtml, isEmpty) | |||
local box = mw.html.create("div") | |||
box:addClass("hero-bar-module") | |||
box:addClass("hero-bar-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 type(extraClasses) == "string" then | |||
box:addClass(extraClasses) | |||
elseif type(extraClasses) == "table" then | |||
for _, c in ipairs(extraClasses) do box:addClass(c) end | |||
end | |||
end | |||
if isEmpty then | |||
box:addClass("hero-bar-module-empty") | |||
end | |||
local body = box:tag("div"):addClass("hero-bar-module-body") | |||
if innerHtml and innerHtml ~= "" then | |||
body:wikitext(innerHtml) | |||
end | |||
return tostring(box) | |||
end | |||
-- moduleBox: wrapper for hero-module (2x2 grid) slot modules. | |||
local function moduleBox(slot, extraClasses, innerHtml, isEmpty) | |||
local box = mw.html.create("div") | |||
box:addClass("hero-module") | |||
box:addClass("hero-module-" .. tostring(slot)) | |||
box:attr("data-hero-module", tostring(slot)) | |||
if extraClasses then | |||
if type(extraClasses) == "string" then | |||
box:addClass(extraClasses) | |||
elseif type(extraClasses) == "table" then | |||
for _, c in ipairs(extraClasses) do box:addClass(c) end | |||
end | |||
end | |||
if isEmpty then | |||
box:addClass("hero-module-empty") | |||
end | |||
local body = box:tag("div"):addClass("hero-module-body") | |||
if innerHtml and innerHtml ~= "" then | |||
body:wikitext(innerHtml) | |||
end | end | ||
return | |||
return tostring(box) | |||
end | end | ||
---------------------------------------------------------------------- | |||
-- Shared helpers (Source + QuickStats + SpecialMechanics) | |||
---------------------------------------------------------------------- | |||
-- formatScalingCompactLines: build compact “Scaling” lines for SourceType. | |||
local function formatScalingCompactLines(scaling) | |||
if type(scaling) ~= "table" then | if type(scaling) ~= "table" then | ||
return | return {} | ||
end | end | ||
local list = scaling | local list = scaling | ||
if #list == 0 then | if #list == 0 then | ||
if scaling.Percent ~= nil or scaling["Scaling ID"] or scaling["Scaling Name"] then | if scaling.Percent ~= nil or scaling["Scaling ID"] or scaling["Scaling Name"] then | ||
list = { scaling } | list = { scaling } | ||
else | else | ||
return | return {} | ||
end | end | ||
end | end | ||
local | local out = {} | ||
for _, s in ipairs(list) do | for _, s in ipairs(list) do | ||
if type(s) == "table" then | if type(s) == "table" then | ||
| Line 492: | Line 574: | ||
local pct = s.Percent | local pct = s.Percent | ||
local pctN = toNum(pct) | local pctN = toNum(pct) | ||
if pctN ~= nil and pctN ~= 0 then | if pctN ~= nil and pctN ~= 0 then | ||
table.insert( | table.insert(out, string.format("%s%% %s", fmtNum(pctN), stat)) | ||
elseif pct ~= nil and tostring(pct) ~= "" and tostring(pct) ~= "0" then | elseif pct ~= nil and tostring(pct) ~= "" and tostring(pct) ~= "0" then | ||
table.insert( | table.insert(out, string.format("%s%% %s", tostring(pct), stat)) | ||
end | end | ||
end | end | ||
end | end | ||
if | return out | ||
return | end | ||
-- basisWordFromFlags: compute “Attack/Magic/Hybrid” from ATK/MATK booleans. | |||
local function basisWordFromFlags(atkFlag, matkFlag) | |||
if atkFlag and matkFlag then | |||
return "Hybrid" | |||
elseif atkFlag then | |||
return "Attack" | |||
elseif matkFlag then | |||
return "Magic Attack" | |||
end | end | ||
return | return nil | ||
end | end | ||
local function | -- legacyPercentAtLevel: compute “Base% + PerLevel%*level” for legacy entries. | ||
if type( | local function legacyPercentAtLevel(entry, level) | ||
if type(entry) ~= "table" then | |||
return nil | return nil | ||
end | end | ||
local | local baseRaw = entry["Base %"] | ||
local perRaw = entry["Per Level %"] | |||
local baseN = toNum(baseRaw) | |||
local perN = toNum(perRaw) | |||
local | if perN ~= nil and perN ~= 0 then | ||
local total = (baseN or 0) + (perN * level) | |||
return fmtNum(total) .. "%" | |||
end | end | ||
if baseN ~= nil then | |||
if | return fmtNum(baseN) .. "%" | ||
end | |||
if baseRaw ~= nil and tostring(baseRaw) ~= "" then | |||
return tostring(baseRaw) .. "%" | |||
end | end | ||
if | return nil | ||
end | |||
-- seriesFromValuePair: normalize Base/Per Level blocks into a level-indexed series. | |||
local function seriesFromValuePair(block, maxLevel) | |||
if type(block) ~= "table" then | |||
return nil | return nil | ||
end | end | ||
local function | local base = block.Base | ||
local per = block["Per Level"] | |||
local function pickUnit(v) | |||
if type(v) == "table" and v.Unit and v.Unit ~= "" then | |||
return v.Unit | |||
end | |||
return nil | return nil | ||
end | |||
local unit = pickUnit(base) or pickUnit(per) | |||
local function fmtAny(v) | |||
local t = formatUnitValue(v) | |||
return t and tostring(t) or (v ~= nil and tostring(v) or nil) | |||
end | end | ||
local | local series = {} | ||
-- Expanded per-level series (wikiprep) | |||
if type(per) == "table" and #per > 0 then | |||
for lv = 1, maxLevel do | |||
local raw = per[lv] or per[#per] | |||
local one = fmtAny(raw) | |||
if one == nil or isZeroish(raw) or isZeroish(one) then | |||
one = "—" | |||
end | |||
series[lv] = one | |||
end | |||
return series | |||
end | |||
-- Empty per-list -> base only | |||
local | if type(per) == "table" and #per == 0 then | ||
if | local one = fmtAny(base) | ||
if one == nil or isZeroish(base) or isZeroish(one) then | |||
one = "—" | |||
end | end | ||
for lv = 1, maxLevel do | |||
series[lv] = one | |||
end | end | ||
return series | |||
end | end | ||
-- Scalar per -> compute base + per*level (fallback) | |||
local baseN = toNum(base) or 0 | |||
local perN = toNum(per) | |||
if | if perN ~= nil then | ||
for lv = 1, maxLevel do | |||
local total = baseN + (perN * lv) | |||
local v = unit and { Value = total, Unit = unit } or total | |||
local one = fmtAny(v) | |||
if one == nil or total == 0 or isZeroish(one) then | |||
one = "—" | |||
end | |||
series[lv] = one | |||
end | |||
return series | |||
end | end | ||
if | |||
-- Base-only scalar | |||
local raw = (base ~= nil) and base or per | |||
local one = fmtAny(raw) | |||
if one == nil then | |||
return nil | |||
end | end | ||
if | if isZeroish(raw) or isZeroish(one) then | ||
one = "—" | |||
end | end | ||
for lv = 1, maxLevel do | |||
series[lv] = one | |||
end | |||
return series | |||
end | |||
if # | -- displayFromSeries: render a series as fixed text or dynSpan (nil if all “—”). | ||
local function displayFromSeries(series, level) | |||
if type(series) ~= "table" or #series == 0 then | |||
return nil | return nil | ||
end | end | ||
return | |||
local any = false | |||
for _, v in ipairs(series) do | |||
if v ~= "—" then | |||
any = true | |||
break | |||
end | |||
end | |||
if not any then | |||
return nil | |||
end | |||
if isFlatList(series) then | |||
return mw.text.nowiki(series[1]) | |||
end | |||
return dynSpan(series, level) | |||
end | end | ||
local function | -- formatAreaSize: human readable area sizing for QuickStats. | ||
if type( | local function formatAreaSize(area) | ||
if type(area) ~= "table" then | |||
return nil | |||
end | |||
local raw = area["Area Size"] | |||
if raw == nil then | |||
return nil | return nil | ||
end | end | ||
local | local name, num | ||
if type(raw) == "table" then | |||
name = raw.Name or raw.ID or raw.Value | |||
num = raw.Value | |||
if raw.Name or raw.ID then | |||
name = raw.Name or raw.ID | |||
end | |||
elseif type(raw) == "string" then | |||
name = raw | |||
elseif type(raw) == "number" then | |||
num = raw | |||
end | |||
if num == nil then | |||
num = toNum(area["Area Value"]) or toNum(area["Area Size Value"]) or toNum(area["Area Number"]) or toNum(area["Area Radius"]) | |||
end | |||
if name ~= nil then | |||
local s = mw.text.trim(tostring(name)) | |||
if s == "" or isNoneLike(s) then | |||
return nil | |||
end | |||
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 | |||
if num ~= nil and num ~= 0 then | |||
return mw.text.nowiki(string.format("(%s)", fmtNum(num))) | |||
end | end | ||
local | return nil | ||
end | |||
-- skillHasAnyDamage: determine if a skill has any meaningful damage (for non-damaging rules). | |||
local function skillHasAnyDamage(rec, maxLevel) | |||
if type(rec.Source) == "table" then | |||
local s = seriesFromValuePair(rec.Source, maxLevel) | |||
if s then | |||
for _, v in ipairs(s) do | |||
if v ~= "—" then return true end | |||
end | |||
end | |||
end | end | ||
if | if type(rec.Damage) == "table" then | ||
local dmg = rec.Damage | |||
for _, key in ipairs({ "Main Damage", "Flat Damage", "Reflect Damage" }) do | |||
local lst = dmg[key] | |||
if type(lst) == "table" and #lst > 0 then | |||
return true | |||
end | |||
end | |||
end | end | ||
return | |||
return false | |||
end | end | ||
local function | -- computeDurationPromotion: promote status-duration into QuickStats when a skill is non-damaging. | ||
if type( | local function computeDurationPromotion(rec, maxLevel) | ||
return nil | 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 | end | ||
local | local apps = rec["Status Applications"] | ||
if type(apps) ~= "table" then return nil end | |||
for idx, app in ipairs(apps) do | |||
table. | 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 | end | ||
local | return nil | ||
if | 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 | end | ||
wrap:tag("div") | |||
:addClass("spiritvale-infobox-title") | |||
:wikitext(title) | |||
return { | |||
inner = tostring(wrap), | |||
classes = "module-herobar-1", | |||
} | |||
end | |||
-- PLUGIN: ReservedInfo (Hero Bar Slot 2 placeholder) - kept for future. | |||
function PLUGINS.ReservedInfo(rec, ctx) | |||
local wrap = mw.html.create("div") | |||
wrap:addClass("sv-herobar-2-wrap") | |||
return { | |||
inner = tostring(wrap), | |||
classes = "module-herobar-2", | |||
} | |||
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 | end | ||
if | return { | ||
inner = tostring(inner), | |||
classes = "module-level-selector", | |||
} | |||
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. | |||
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) == "string" and x ~= "" then | |||
return x | |||
end | |||
return nil | return nil | ||
end | end | ||
local function | -- hitsDisplay: find + render Hits from multiple possible structured locations. | ||
local function hitsDisplay() | |||
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 | |||
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 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 | return nil | ||
end | 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 not typ or isNoneLike(typ) then | |||
return nil | |||
end | |||
local details = {} | |||
local pct = formatUnitValue(c.Percent) | |||
if | if pct and not isZeroish(pct) then | ||
table.insert(details, mw.text.nowiki(pct)) | |||
end | end | ||
if | |||
local dur = formatUnitValue(c.Duration) | |||
if dur and not isZeroish(dur) then | |||
table.insert(details, mw.text.nowiki(dur)) | |||
end | end | ||
if #details > 0 then | |||
return mw.text.nowiki(typ) .. " (" .. table.concat(details, ", ") .. ")" | |||
end | end | ||
return ( | 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 | end | ||
local | -- 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 | |||
-- Hits: only render when present and meaningful | |||
if hits then | |||
addChunk("hits", "Hits", hits) | |||
end | |||
end | end | ||
-- | -- Target + Cast | ||
local | local tgt = valName(typeBlock.Target or typeBlock["Target Type"]) | ||
if | 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 | end | ||
local | -- Combo (moved here) | ||
local combo = comboDisplay() | |||
if combo then | |||
addChunk("combo", "Combo", combo) | |||
end | end | ||
local | 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 | |||
local function | -- sourceValueForLevel: dynamic formatting for structured Source blocks. | ||
if type( | local function sourceValueForLevel(src) | ||
if type(src) ~= "table" then | |||
return nil | return nil | ||
end | end | ||
local per = | local per = src["Per Level"] | ||
if type(per) == "table" and #per > 0 then | if type(per) == "table" and #per > 0 then | ||
if isFlatList(per) 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 series = {} | local series = {} | ||
for _, v in ipairs(per) do | for _, v in ipairs(per) do | ||
| Line 685: | Line 1,114: | ||
end | end | ||
return valuePairDynamicValueOnly(src, maxLevel, level) | |||
return | |||
end | end | ||
if type(rec.Source) == "table" then | |||
local | 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 | ||
if | -- 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 | 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 | end | ||
elseif type(refl) == "table" and #refl > 0 and type(refl[1]) == "table" then | |||
table | 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 | end | ||
local scalingLines = formatScalingCompactLines(scaling) | |||
local hasSource = (sourceVal ~= nil and tostring(sourceVal) ~= "") | |||
local hasScaling = (type(scalingLines) == "table" and #scalingLines > 0) | |||
if | if (not hasSource) and (not hasScaling) then | ||
return nil | return nil | ||
end | end | ||
local | local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "") | ||
local | 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 | ||
end | end | ||
return { | |||
inner = tostring(wrap), | |||
classes = extra, | |||
} | |||
end | end | ||
-- PLUGIN: QuickStats (Hero Module Slot 2) - 3x2 grid (range/area/cost/cast/cd/duration). | |||
function PLUGINS.QuickStats(rec, ctx) | |||
local level = ctx.level or 1 | |||
local maxLevel = ctx.maxLevel or 1 | |||
local promo = ctx.promo | |||
local | 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 | |||
rangeVal = mw.text.nowiki(formatUnitValue(mech.Range) or tostring(mech.Range)) | |||
end | end | ||
else | |||
local | local t = mw.text.trim(tostring(mech.Range)) | ||
if | if t ~= "" and not isNoneLike(t) then | ||
rangeVal = mw.text.nowiki(t) | |||
end | end | ||
end | end | ||
end | end | ||
if | -- Area | ||
local areaVal = formatAreaSize(mech.Area) | |||
-- 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 function | -- Cost: MP + HP | ||
local function labeledSeries(block, label) | |||
return nil | 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 | end | ||
local | local mpS = labeledSeries(rc["Mana Cost"], "MP") | ||
for | local hpS = labeledSeries(rc["Health Cost"], "HP") | ||
if | |||
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 | ||
return | |||
addCell("Range", rangeVal) | |||
addCell("Area", areaVal) | |||
addCell("Cost", costVal) | |||
addCell("Cast Time", castVal) | |||
addCell("Cooldown", cdVal) | |||
addCell("Duration", durVal) | |||
return { | |||
inner = tostring(grid), | |||
classes = "module-quick-stats", | |||
} | |||
end | end | ||
---------------------------------------------------------------------- | -- PLUGIN: SpecialMechanics (Hero Module Slot 3) | ||
-- | -- Shows: | ||
--------------------------------------------------------------------- | -- - Flags (deduped) | ||
-- - Special mechanics (mech.Effects) | |||
-- NOTE: Combo has been moved to 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 | |||
------------------------------------------------------------------ | |||
-- Flags (flat, de-duped) | |||
------------------------------------------------------------------ | |||
local flagSet = {} | |||
-- Filter out mechanics you said to remove/ignore here. | |||
local denyFlags = { | |||
["self centered"] = true, | |||
["self-centred"] = true, | |||
["self centered"] = true, | |||
["bond"] = true, | |||
["combo"] = true, | |||
["hits"] = true, | |||
} | |||
local function | local function allowFlag(name) | ||
if not name then return false end | |||
return false | 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 | local function addFlags(sub) | ||
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 | |||
for | flagSet[tostring(k)] = true | ||
if type(v) == " | |||
end | end | ||
end | end | ||
end | end | ||
local flags = {} | |||
for k, _ in pairs(flagSet) do table.insert(flags, k) end | |||
table.sort(flags) | |||
if | |||
------------------------------------------------------------------ | |||
-- 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 | |||
local block = effects[name] | |||
if type(block) == "table" 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 | |||
if value then | |||
table.insert(mechItems, { label = tostring(name), value = value }) | |||
end | |||
end | |||
end | |||
end | end | ||
local | local hasFlags = (#flags > 0) | ||
local | 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 | 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 | local layout = root:tag("div"):addClass("sv-sm-layout") | ||
layout:addClass("sv-sm-count-" .. tostring(count)) | |||
if | -- 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 | end | ||
if | |||
-- 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 | end | ||
return | return { | ||
inner = tostring(root), | |||
classes = "module-special-mechanics", | |||
} | |||
end | end | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- | -- Generic slot renderers | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
local function | -- 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 | |||
inner = (inner ~= nil) and tostring(inner) or "" | |||
end | |||
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 | |||
-- renderHeroBarSlot: render a hero-bar slot by plugin assignment. | |||
local function renderHeroBarSlot(slotIndex, rec, ctx) | |||
local pluginName = HERO_BAR_SLOT_ASSIGNMENT[slotIndex] | |||
if not pluginName then | |||
return heroBarBox(slotIndex, nil, "", true) | |||
end | |||
local res = safeCallPlugin(pluginName, rec, ctx) | |||
if not res or not res.inner or res.inner == "" then | |||
return heroBarBox(slotIndex, nil, "", true) | |||
end | |||
return | return heroBarBox(slotIndex, res.classes, res.inner, false) | ||
end | end | ||
local function | -- renderModuleSlot: render a hero-module slot by plugin assignment. | ||
if | local function renderModuleSlot(slotIndex, rec, ctx) | ||
return nil | local pluginName = HERO_MODULE_SLOT_ASSIGNMENT[slotIndex] | ||
if not pluginName then | |||
return moduleBox(slotIndex, nil, "", true) | |||
end | end | ||
local | local res = safeCallPlugin(pluginName, rec, ctx) | ||
if not res or not res.inner or res.inner == "" then | |||
return moduleBox(slotIndex, nil, "", true) | |||
end | end | ||
return moduleBox(slotIndex, res.classes, res.inner, false) | |||
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 | end | ||
return tostring(grid) | |||
return tostring( | |||
end | end | ||
local function | -- addHeroModulesRow: add the hero-modules row into the infobox table. | ||
if not | local function addHeroModulesRow(tbl, modulesUI) | ||
if not modulesUI or modulesUI == "" then | |||
return | return | ||
end | end | ||
local row = tbl:tag("tr") | local row = tbl:tag("tr") | ||
row:addClass("hero-modules-row") | |||
local cell = row:tag("td") | local cell = row:tag("td") | ||
cell:attr("colspan", 2) | cell:attr("colspan", 2) | ||
cell:addClass(" | cell:addClass("hero-modules-cell") | ||
cell:wikitext(modulesUI) | |||
end | |||
---------------------------------------------------------------------- | |||
-- Infobox builder | |||
---------------------------------------------------------------------- | |||
-- buildInfobox: render a single skill infobox. | |||
local function buildInfobox(rec, opts) | local function buildInfobox(rec, opts) | ||
opts = opts or {} | opts = opts or {} | ||
| Line 1,031: | Line 1,581: | ||
local maxLevel = tonumber(rec["Max Level"]) or 1 | local maxLevel = tonumber(rec["Max Level"]) or 1 | ||
if maxLevel < 1 then | if maxLevel < 1 then maxLevel = 1 end | ||
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 | end | ||
ctx.promo = computeDurationPromotion(rec, maxLevel) | |||
local root = mw.html.create("table") | local root = mw.html.create("table") | ||
root:addClass("spiritvale-skill-infobox") | root:addClass("spiritvale-skill-infobox") | ||
root:addClass("sv-skill-card") | root:addClass("sv-skill-card") | ||
root:attr("data-max-level", tostring(maxLevel)) | root:attr("data-max-level", tostring(maxLevel)) | ||
root:attr("data-level", tostring(level)) | root:attr("data-level", tostring(level)) | ||
if opts.inList then | if opts.inList then | ||
root:addClass("sv-skill-inlist") | root:addClass("sv-skill-inlist") | ||
end | end | ||
local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID) | |||
if internalId then | |||
root:attr("data-skill-id", internalId) | |||
local | end | ||
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") | ||
heroRow:addClass("sv-hero-title-row") | heroRow:addClass("sv-hero-title-row") | ||
heroRow:addClass("hero-title-bar") | |||
local heroCell = heroRow:tag("th") | local heroCell = heroRow:tag("th") | ||
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)) | |||
-- Description Bar | |||
if desc ~= "" then | if desc ~= "" then | ||
local descRow = root:tag("tr") | local descRow = root:tag("tr") | ||
descRow:addClass("spiritvale-infobox-main") | descRow:addClass("spiritvale-infobox-main") | ||
descRow:addClass("sv-hero-desc-row") | descRow:addClass("sv-hero-desc-row") | ||
descRow:addClass("hero-description-bar") | |||
local descCell = descRow:tag("td") | local descCell = descRow:tag("td") | ||
| Line 1,099: | Line 1,652: | ||
end | end | ||
-- | -- Modules row | ||
addHeroModulesRow(root, buildHeroModulesUI(rec, ctx)) | |||
-- Users (hide on direct skill page) | |||
-- Users ( | |||
if showUsers then | if showUsers then | ||
local users = rec.Users or {} | local users = rec.Users or {} | ||
addRow(root, "Classes", listToText(users.Classes)) | addRow(root, "Classes", listToText(users.Classes), "sv-row-users", "Users.Classes") | ||
addRow(root, "Summons", listToText(users.Summons)) | addRow(root, "Summons", listToText(users.Summons), "sv-row-users", "Users.Summons") | ||
addRow(root, "Monsters", listToText(users.Monsters)) | addRow(root, "Monsters", listToText(users.Monsters), "sv-row-users", "Users.Monsters") | ||
addRow(root, "Events", listToText(users.Events)) | addRow(root, "Events", listToText(users.Events), "sv-row-users", "Users.Events") | ||
end | end | ||
-- Requirements | -- Requirements | ||
local req = rec.Requirements or {} | local req = rec.Requirements or {} | ||
local hasReq = | |||
(type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0) or | |||
(type(req["Required Weapons"]) == "table" and #req["Required Weapons"] > 0) or | |||
(type(req["Required Stances"]) == "table" and #req["Required Stances"] > 0) | |||
if hasReq then | |||
if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then | if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then | ||
local skillParts = {} | local skillParts = {} | ||
| Line 1,136: | Line 1,683: | ||
end | end | ||
end | end | ||
addRow(root, "Required Skills", table.concat(skillParts, ", ")) | addRow(root, "Required Skills", table.concat(skillParts, ", "), "sv-row-req", "Requirements.Required Skills") | ||
end | end | ||
addRow(root, "Required Weapons", listToText(req["Required Weapons"])) | addRow(root, "Required Weapons", listToText(req["Required Weapons"]), "sv-row-req", "Requirements.Required Weapons") | ||
addRow(root, "Required Stances", listToText(req["Required Stances"])) | addRow(root, "Required Stances", listToText(req["Required Stances"]), "sv-row-req", "Requirements.Required Stances") | ||
end | end | ||
-- Mechanics (keep small extras only) | |||
-- Mechanics | |||
local mech = rec.Mechanics or {} | local mech = rec.Mechanics or {} | ||
if next(mech) ~= nil then | if next(mech) ~= nil then | ||
if mech["Autocast Multiplier"] ~= nil then | if mech["Autocast Multiplier"] ~= nil then | ||
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"])) | addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier") | ||
end | end | ||
end | |||
addRow(root, " | -- Legacy damage breakdown (only when Source absent) | ||
if type(rec.Source) ~= "table" then | |||
local dmg = rec.Damage or {} | |||
if next(dmg) ~= nil then | |||
local main = dmg["Main Damage"] | |||
local mainNonHeal, healOnly = {}, {} | |||
if type(main) == "table" then | |||
for _, d in ipairs(main) do | |||
if type(d) == "table" and d.Type == "Healing" then | |||
table.insert(healOnly, d) | |||
else | |||
table.insert(mainNonHeal, d) | |||
end | |||
end | |||
end | |||
addRow(root, "Main Damage", formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main 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 | end | ||
-- | -- Status rows | ||
local function formatStatusApplications(list, suppressDurationIndex) | |||
if type(list) ~= "table" or #list == 0 then return nil end | |||
if type( | |||
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 | end | ||
table.insert(parts, seg) | |||
end | end | ||
end | end | ||
return (#parts > 0) and table.concat(parts, "<br />") or nil | |||
end | |||
local function formatStatusRemoval(list) | |||
if type(list) ~= "table" or #list == 0 then return nil end | |||
local | 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 | |||
end | 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(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil | |||
local statusApps = formatStatusApplications(rec["Status Applications"], suppressIdx) | |||
local statusRem = formatStatusRemoval(rec["Status Removal"]) | |||
local statusApps = formatStatusApplications(rec["Status Applications"], | |||
local statusRem = formatStatusRemoval(rec["Status Removal"] | |||
if statusApps or statusRem then | if statusApps or statusRem then | ||
addRow(root, "Applies", statusApps) | addRow(root, "Applies", statusApps, "sv-row-status", "Status Applications") | ||
addRow(root, "Removes", statusRem) | addRow(root, "Removes", statusRem, "sv-row-status", "Status Removal") | ||
end | end | ||
-- Events | -- 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 | |||
local eventsText = formatEvents(rec.Events) | local eventsText = formatEvents(rec.Events) | ||
if eventsText then | if eventsText then | ||
addRow(root, "Triggers", eventsText) | addRow(root, "Triggers", eventsText, "sv-row-meta", "Events") | ||
end | end | ||
-- Notes | -- Notes | ||
if type(rec.Notes) == "table" and #rec.Notes > 0 then | if type(rec.Notes) == "table" and #rec.Notes > 0 then | ||
addRow(root, "Notes", table.concat(rec.Notes, "<br />")) | addRow(root, "Notes", table.concat(rec.Notes, "<br />"), "sv-row-meta", "Notes") | ||
end | end | ||
| Line 1,266: | Line 1,847: | ||
if #matches == 0 then | if #matches == 0 then | ||
return string.format( | return string.format("<strong>No skills found for:</strong> %s", mw.text.nowiki(userName)) | ||
end | end | ||
local root = mw.html.create("div") | local root = mw.html.create("div") | ||
root:addClass("sv-skill-collection") | 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 item = root:tag("div"):addClass("sv-skill-item") | ||
item:wikitext(buildInfobox(rec, { showUsers = false, inList = true })) | item:wikitext(buildInfobox(rec, { showUsers = false, inList = true })) | ||