Module:GameSkills: Difference between revisions
From SpiritVale Wiki
More actions
No edit summary |
No edit summary |
||
| Line 1: | Line 1: | ||
-- Module:GameSkills | |||
-- | |||
-- Phase 6.5+ (Plug-in Slot Architecture) | |||
-- | |||
-- Standard Hero Layout (reused across the wiki): | -- Standard Hero Layout (reused across the wiki): | ||
-- 1) hero-title-bar (TOP BAR, 2 slots: herobar 1..2) | -- 1) hero-title-bar (TOP BAR, 2 slots: herobar 1..2) | ||
-- - Herobar 1: Icon + Skill Name (left) | -- - Herobar 1: Icon + Skill Name (left) | ||
-- - Herobar 2: Reserved for compact info (right) | -- - Herobar 2: Reserved for compact info (right) | ||
-- 2) hero-description-bar (description strip) | -- 2) hero-description-bar (description strip) [special-cased for now] | ||
-- 3) hero-modules row (4 slots: hero-module-1..4) | -- 3) hero-modules row (4 slots: hero-module-1..4) | ||
-- - Slot 1: | -- - Slot 1: Level Selector | ||
-- - Slot 2: | -- - Slot 2: Skill Type | ||
-- - Slot 3: | -- - Slot 3: SourceType (Modifier + Source + Scaling) OR blank | ||
-- - Slot 4: | -- - Slot 4: Quick Stats (Range/Area/Cost/Cast/Cooldown/Duration) | ||
-- | -- | ||
-- Requires Common.js logic that updates: | -- Requires Common.js logic that updates: | ||
| Line 372: | Line 376: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- Formatting helpers | -- Formatting helpers (legacy + mechanics/status/etc.) | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
| Line 394: | Line 398: | ||
end | end | ||
local function formatDamageEntry(entry, maxLevel, level) | local function formatDamageEntry(entry, maxLevel, level) | ||
if type(entry) ~= "table" then | if type(entry) ~= "table" then | ||
| Line 598: | Line 601: | ||
local detail = {} | local detail = {} | ||
-- Duration (optionally suppressed if | -- Duration (optionally suppressed if QuickStats promoted it) | ||
if idx ~= suppressDurationIndex and type(s.Duration) == "table" then | if idx ~= suppressDurationIndex and type(s.Duration) == "table" then | ||
local t = valuePairDynamicText("Duration", s.Duration, maxLevel, level, "; ") | local t = valuePairDynamicText("Duration", s.Duration, maxLevel, level, "; ") | ||
| Line 735: | Line 738: | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- Hero bar | -- Plug-in slot config (EDIT THESE TABLES ONLY to rearrange layout) | ||
-- | ---------------------------------------------------------------------- | ||
-- | |||
-- Hero bar: 2 slots | |||
local HERO_BAR_SLOT_ASSIGNMENT = { | |||
[1] = "IconName", | |||
[2] = "ReservedInfo", -- set to nil to blank the slot entirely | |||
} | |||
-- Modules row: 4 slots | |||
local HERO_MODULE_SLOT_ASSIGNMENT = { | |||
[1] = "LevelSelector", | |||
[2] = "SkillType", | |||
[3] = "SourceType", -- returns nil/"" when not applicable -> becomes blank slot | |||
[4] = "QuickStats", | |||
} | |||
---------------------------------------------------------------------- | |||
-- Slot scaffolds (stable containers; plug-ins provide inner content) | |||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
| Line 768: | Line 787: | ||
end | end | ||
local function | 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 | if isEmpty then | ||
box:addClass("hero-module-empty") | |||
end | end | ||
local body = box:tag("div"):addClass("hero-module-body") | |||
if innerHtml and innerHtml ~= "" then | |||
:wikitext( | body:wikitext(innerHtml) | ||
end | |||
return tostring(box) | |||
return tostring( | |||
end | end | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
-- | -- Shared helpers used by plug-ins (Source + QuickStats) | ||
---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||
local function | local function formatScalingCompactLines(scaling) | ||
if type(scaling) ~= "table" then | |||
return {} | |||
end | |||
if | local list = scaling | ||
if | if #list == 0 then | ||
if scaling.Percent ~= nil or scaling["Scaling ID"] or scaling["Scaling Name"] then | |||
list = { scaling } | |||
else | |||
return {} | |||
end | end | ||
end | end | ||
if | local out = {} | ||
for _, s in ipairs(list) do | |||
if type(s) == "table" then | |||
local stat = s["Scaling Name"] or s["Scaling ID"] or "Unknown" | |||
local pct = s.Percent | |||
local pctN = toNum(pct) | |||
if pctN ~= nil and pctN ~= 0 then | |||
table.insert(out, string.format("%s%% %s", fmtNum(pctN), stat)) | |||
elseif pct ~= nil and tostring(pct) ~= "" and tostring(pct) ~= "0" then | |||
table.insert(out, string.format("%s%% %s", tostring(pct), stat)) | |||
end | |||
end | |||
end | end | ||
return | return out | ||
end | end | ||
local function | local function basisWordFromFlags(atkFlag, matkFlag) | ||
return | if atkFlag and matkFlag then | ||
return "Hybrid" | |||
elseif atkFlag then | |||
return "Attack" | |||
elseif matkFlag then | |||
return "Magic Attack" | |||
end | |||
return nil | |||
end | end | ||
local function sourceValueForLevel(src, maxLevel, level) | |||
if type(src) ~= "table" then | |||
return nil | |||
local function | end | ||
local per = src["Per Level"] | |||
if type(per) == "table" and #per > 0 then | |||
if isFlatList(per) then | |||
local one = formatUnitValue(per[1]) or tostring(per[1]) | |||
local show = formatUnitValue(src.Base) or one | |||
return show and mw.text.nowiki(show) or nil | |||
end | |||
local series = {} | |||
for _, v in ipairs(per) do | |||
table.insert(series, formatUnitValue(v) or tostring(v)) | |||
end | |||
return dynSpan(series, level) | |||
end | end | ||
return | return valuePairDynamicValueOnly(src, maxLevel, level) | ||
end | end | ||
local function legacyPercentAtLevel(entry, level) | |||
if type(entry) ~= "table" then | |||
local function | |||
return nil | return nil | ||
end | end | ||
local | local baseRaw = entry["Base %"] | ||
local perRaw = entry["Per Level %"] | |||
local baseN = toNum(baseRaw) | |||
local perN = toNum(perRaw) | |||
if perN ~= nil and perN ~= 0 then | |||
local total = (baseN or 0) + (perN * level) | |||
return fmtNum(total) .. "%" | |||
end | |||
if baseN ~= nil then | |||
return fmtNum(baseN) .. "%" | |||
end | end | ||
if baseRaw ~= nil and tostring(baseRaw) ~= "" then | |||
if | return tostring(baseRaw) .. "%" | ||
end | end | ||
return nil | |||
return | |||
end | end | ||
local function seriesFromValuePair(block, maxLevel) | |||
if type(block) ~= "table" then | |||
return nil | |||
end | |||
local | local base = block.Base | ||
local per = block["Per Level"] | |||
local | local function pickUnit(v) | ||
if type(v) == "table" and v.Unit and v.Unit ~= "" then | |||
return v.Unit | |||
return | |||
end | end | ||
return nil | |||
end | end | ||
local unit = pickUnit(base) or pickUnit(per) | |||
local | local function fmtAny(v) | ||
local t = formatUnitValue(v) | |||
return t and tostring(t) or (v ~= nil and tostring(v) or nil) | |||
end | end | ||
local series = {} | |||
-- Preferred: expanded series (wikiprep) | |||
if | 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 | |||
return | one = "—" | ||
end | |||
series[lv] = one | |||
end | |||
return series | |||
end | end | ||
-- Empty per-list -> base only | |||
if type(per) == "table" and #per == 0 then | |||
local one = fmtAny(base) | |||
if one == nil or isZeroish(base) or isZeroish(one) then | |||
one = "—" | |||
if type(per) == "table" and #per | |||
end | end | ||
for lv = 1, maxLevel do | |||
series[lv] = one | |||
end | end | ||
return | return series | ||
end | end | ||
-- Scalar per -> compute base + per*level (fallback) | |||
local baseN = toNum(base) or 0 | |||
local perN = toNum(per) | |||
local | if perN ~= nil then | ||
for lv = 1, maxLevel do | |||
return | 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 | ||
-- Base-only scalar | |||
local | local raw = (base ~= nil) and base or per | ||
local one = fmtAny(raw) | |||
local | if one == nil then | ||
return nil | |||
if | |||
return | |||
end | end | ||
if isZeroish(raw) or isZeroish(one) then | |||
if | one = "—" | ||
end | end | ||
for lv = 1, maxLevel do | |||
series[lv] = one | |||
end | end | ||
return series | |||
return | |||
end | end | ||
local function | local function displayFromSeries(series, level) | ||
if type(series) ~= "table" or #series == 0 then | |||
return nil | |||
if type( | |||
end | end | ||
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 | |||
local function formatAreaSize(area) | |||
if type(area) ~= "table" then | |||
return nil | |||
end | |||
local raw = area["Area Size"] | |||
if raw == nil then | |||
return nil | |||
end | |||
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 | 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 | end | ||
local | 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 | ||
return nil | |||
end | |||
if | local function skillHasAnyDamage(rec, maxLevel) | ||
local | 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 | 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 false | |||
end | |||
local function computeDurationPromotion(rec, maxLevel) | |||
for _, | -- Only: | ||
-- - non-damaging skills | |||
-- - AND missing/blank Duration in Basic Timings | |||
-- - AND a Status Application has a Duration block | |||
-- Then: promote that status duration into QuickStats and suppress it in Applies row. | |||
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 | end | ||
-- if durS is nil OR all "—", allow promotion | |||
local apps = rec["Status Applications"] | |||
end | if type(apps) ~= "table" then return nil end | ||
for idx, app in ipairs(apps) do | |||
if type(app) == "table" and type(app.Duration) == "table" then | |||
local s = seriesFromValuePair(app.Duration, maxLevel) | |||
if s then | |||
for _, v in ipairs(s) do | |||
if v ~= "—" then | |||
return { | |||
durationBlock = app.Duration, | |||
suppressDurationIndex = idx, | |||
local | } | ||
end | |||
end | |||
end | |||
end | |||
end | end | ||
return nil | |||
end | |||
---------------------------------------------------------------------- | |||
-- Plug-ins (name -> { inner = "...", classes = "..."/{...} } OR nil/"") | |||
---------------------------------------------------------------------- | |||
local PLUGINS = {} | |||
-- 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 | local wrap = mw.html.create("div") | ||
wrap:addClass("sv-herobar-1-wrap") | |||
if icon and icon ~= "" then | |||
if | 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 | |||
-- Hero Bar Slot 2: Reserved container (kept wired for future use) | |||
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 | |||
-- Module Slot 1: Level Selector | |||
local | function PLUGINS.LevelSelector(rec, ctx) | ||
local | 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 | ||
return { | |||
inner = tostring(inner), | |||
classes = "module-level-selector", | |||
} | |||
end | end | ||
-- Module Slot 2: Skill Type | |||
if type( | function PLUGINS.SkillType(rec, ctx) | ||
local typeBlock = (type(rec.Type) == "table") and rec.Type or {} | |||
local hideDamageAndElement = (ctx.nonDamaging == true) | |||
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 | local grid = mw.html.create("div") | ||
grid:addClass("sv-type-grid") | |||
if v | |||
local added = false | |||
local function addChunk(label, rawVal) | |||
local v = valName(rawVal) | |||
if not v or v == "" then return end | |||
added = true | |||
local chunk = grid:tag("div"):addClass("sv-type-chunk") | |||
chunk:tag("div"):addClass("sv-type-label"):wikitext(mw.text.nowiki(label)) | |||
chunk:tag("div"):addClass("sv-type-value"):wikitext(mw.text.nowiki(v)) | |||
end | end | ||
if not | |||
if not hideDamageAndElement then | |||
addChunk("Damage", typeBlock.Damage or typeBlock["Damage Type"]) | |||
addChunk("Element", typeBlock.Element or typeBlock["Element Type"]) | |||
end | end | ||
addChunk("Target", typeBlock.Target or typeBlock["Target Type"]) | |||
addChunk("Cast", typeBlock.Cast or typeBlock["Cast Type"]) | |||
return | return { | ||
inner = added and tostring(grid) or "", | |||
classes = "module-skill-type", | |||
} | |||
end | end | ||
-- Module Slot 3: SourceType (Modifier + Source + Scaling) OR nil for blank | |||
function PLUGINS.SourceType(rec, ctx) | |||
local level = ctx.level or 1 | |||
local maxLevel = ctx.maxLevel or 1 | |||
local | local basisWord = nil | ||
local sourceKind = nil | |||
local sourceVal = nil | |||
local scaling = nil | |||
local | if type(rec.Source) == "table" then | ||
local src = rec.Source | |||
local atkFlag = (src["ATK-Based"] == true) | |||
local matkFlag = (src["MATK-Based"] == true) | |||
basisWord = basisWordFromFlags(atkFlag, matkFlag) | |||
sourceKind = src.Type or ((src.Healing == true) and "Healing") or "Damage" | |||
sourceVal = sourceValueForLevel(src, maxLevel, level) | |||
scaling = src.Scaling | |||
end | end | ||
-- | -- Fallback to legacy Damage lists if Source absent | ||
if | 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 | 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 | |||
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 | 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 (not hasSource) and (not hasScaling) then | |||
return nil | |||
local | end | ||
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") | |||
if | elseif hasScaling and (not hasSource) then | ||
table.insert(extra, "sv-only-scaling") | |||
end | end | ||
local wrap = mw.html.create("div") | |||
wrap:addClass("sv-source-grid") | |||
-- | if hasMod then | ||
local | local modCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-modifier") | ||
modCol:tag("div"):addClass("sv-source-pill"):wikitext("Modifier") | |||
modCol:tag("div"):addClass("sv-modifier-value"):wikitext(mw.text.nowiki(basisWord)) | |||
end | |||
if hasSource then | |||
local sourceCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-main") | |||
sourceCol:tag("div"):addClass("sv-source-pill"):wikitext(mw.text.nowiki(sourceKind or "Source")) | |||
sourceCol:tag("div"):addClass("sv-source-value"):wikitext(sourceVal) | |||
end | |||
-- | if hasScaling then | ||
local scalingCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-scaling") | |||
scalingCol:tag("div"):addClass("sv-source-pill"):wikitext("Scaling") | |||
local list = scalingCol:tag("div"):addClass("sv-scaling-list") | |||
for _, line in ipairs(scalingLines) do | |||
list:tag("div"):addClass("sv-scaling-item"):wikitext(mw.text.nowiki(line)) | |||
end | |||
end | |||
return { | |||
inner = tostring(wrap), | |||
classes = extra, | |||
} | |||
end | end | ||
-- Module Slot 4: Quick Stats (3x2) | |||
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 | local function dash() return "—" end | ||
-- Range (0 => —) | |||
local rangeVal = nil | |||
if mech.Range ~= nil and not isNoneLike(mech.Range) then | |||
local | local n = toNum(mech.Range) | ||
if n ~= nil then | |||
if | if n ~= 0 then | ||
rangeVal = mw.text.nowiki(formatUnitValue(mech.Range) or tostring(mech.Range)) | |||
end | |||
else | 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 | ||
local | -- Area | ||
local areaVal = formatAreaSize(mech.Area) | |||
local | -- Timings | ||
local castSeries = seriesFromValuePair(bt["Cast Time"], maxLevel) | |||
local cdSeries = seriesFromValuePair(bt["Cooldown"], maxLevel) | |||
local durSeries = seriesFromValuePair(bt["Duration"], maxLevel) | |||
local | local castVal = displayFromSeries(castSeries, level) | ||
local cdVal = displayFromSeries(cdSeries, level) | |||
local durVal = displayFromSeries(durSeries, level) | |||
-- Promote status duration if needed | |||
if (durVal == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then | |||
durSeries = seriesFromValuePair(promo.durationBlock, maxLevel) | |||
durVal = displayFromSeries(durSeries, level) | |||
end | end | ||
-- Cost: combine MP + HP; never show "0 HP" (seriesFromValuePair already turns 0 into "—") | |||
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 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 | |||
local costVal = displayFromSeries(costSeries, level) | |||
local grid = mw.html.create("div") | |||
grid:addClass("sv-m4-grid") | |||
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 | |||
local | addCell("Range", rangeVal) | ||
if type( | 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 | |||
---------------------------------------------------------------------- | |||
-- Generic slot renderers | |||
---------------------------------------------------------------------- | |||
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 | |||
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 | |||
local function renderHeroBarSlot(slotIndex, rec, ctx) | |||
local pluginName = HERO_BAR_SLOT_ASSIGNMENT[slotIndex] | |||
end | 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 heroBarBox(slotIndex, res.classes, res.inner, false) | |||
end | |||
local function renderModuleSlot(slotIndex, rec, ctx) | |||
local pluginName = HERO_MODULE_SLOT_ASSIGNMENT[slotIndex] | |||
if not pluginName then | |||
return moduleBox(slotIndex, nil, "", true) | |||
end | |||
local res = safeCallPlugin(pluginName, rec, ctx) | |||
if not res or not res.inner or res.inner == "" then | |||
return moduleBox(slotIndex, nil, "", true) | |||
end | |||
return moduleBox(slotIndex, res.classes, res.inner, false) | |||
end | |||
---------------------------------------------------------------------- | |||
-- UI builders (hero bar + modules grid) | |||
---------------------------------------------------------------------- | |||
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 | |||
local function buildHeroModulesUI(rec, ctx) | |||
local grid = mw.html.create("div") | |||
grid:addClass("hero-modules-grid") | |||
for slot = 1, 4 do | |||
grid:wikitext(renderModuleSlot(slot, rec, ctx)) | |||
end | |||
return tostring(grid) | |||
end | |||
local function addHeroModulesRow(tbl, modulesUI) | |||
if not modulesUI or modulesUI == "" then | |||
return | |||
end | |||
local row = tbl:tag("tr") | |||
row:addClass("hero-modules-row") | |||
local cell = row:tag("td") | |||
cell:attr("colspan", 2) | |||
cell:addClass("hero-modules-cell") | |||
cell:wikitext(modulesUI) | |||
end | |||
---------------------------------------------------------------------- | |||
-- Infobox builder | |||
---------------------------------------------------------------------- | |||
local function buildInfobox(rec, opts) | |||
opts = opts or {} | |||
local showUsers = (opts.showUsers ~= false) | |||
local maxLevel = tonumber(rec["Max Level"]) or 1 | |||
if maxLevel < 1 then maxLevel = 1 end | |||
local level = clamp(maxLevel, 1, maxLevel) | |||
-- ctx passed to every plug-in (the only allowed “shared state”) | |||
local ctx = { | |||
maxLevel = maxLevel, | |||
level = level, | |||
-- computed below: | |||
nonDamaging = false, | |||
promo = nil, | |||
} | |||
-- Determine “non-damaging” for SkillType (hide Damage/Element) | |||
do | do | ||
local dmgVal = nil | local dmgVal = nil | ||
| Line 1,479: | Line 1,599: | ||
end | end | ||
end | end | ||
nonDamaging = isNoneLike(dmgVal) or (not skillHasAnyDamage(rec, maxLevel)) | ctx.nonDamaging = isNoneLike(dmgVal) or (not skillHasAnyDamage(rec, maxLevel)) | ||
end | end | ||
-- Duration promotion (for QuickStats + Applies suppression) | |||
ctx.promo = computeDurationPromotion(rec, maxLevel) | |||
local root = mw.html.create("table") | local root = mw.html.create("table") | ||
| Line 1,536: | Line 1,620: | ||
end | 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") | ||
| Line 1,548: | Line 1,631: | ||
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 (special-cased for now) | |||
if desc ~= "" then | if desc ~= "" then | ||
local descRow = root:tag("tr") | local descRow = root:tag("tr") | ||
| Line 1,569: | Line 1,652: | ||
end | end | ||
local modulesUI | -- Modules row | ||
local modulesUI = buildHeroModulesUI(rec, ctx) | |||
addHeroModulesRow(root, modulesUI) | addHeroModulesRow(root, modulesUI) | ||
-- Users (hide on direct skill page) | |||
if showUsers then | if showUsers then | ||
local users = rec.Users or {} | local users = rec.Users or {} | ||
| Line 1,580: | Line 1,665: | ||
end | end | ||
-- Requirements | |||
local req = rec.Requirements or {} | local req = rec.Requirements or {} | ||
local hasReq = | local hasReq = | ||
| Line 1,605: | Line 1,691: | ||
end | end | ||
-- Mechanics (detailed rows still shown; Range/Area/Timings/Cost are in QuickStats) | |||
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"]), "sv-row-mech", "Mechanics.Autocast Multiplier") | addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier") | ||
| Line 1,617: | Line 1,702: | ||
end | end | ||
-- Source + Scaling are displayed in | -- Source + Scaling are displayed in SourceType module now, so we do NOT repeat them as body rows. | ||
-- Keep the detailed legacy damage breakdown rows only (Main/Flat/Reflect/Healing). | -- Keep the detailed legacy damage breakdown rows only (Main/Flat/Reflect/Healing) when Source missing. | ||
if type(rec.Source) ~= "table" then | if type(rec.Source) ~= "table" then | ||
local dmg = rec.Damage or {} | local dmg = rec.Damage or {} | ||
| Line 1,645: | Line 1,730: | ||
end | end | ||
-- Modifiers flags | |||
local modsText = formatModifiers(rec.Modifiers) | local modsText = formatModifiers(rec.Modifiers) | ||
if modsText then | if modsText then | ||
| Line 1,650: | Line 1,736: | ||
end | end | ||
local suppressIdx = (type(promo) == "table") and promo.suppressDurationIndex or nil | -- Status (suppress promoted duration line if needed) | ||
local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil | |||
local statusApps = formatStatusApplications(rec["Status Applications"], maxLevel, level, suppressIdx) | local statusApps = formatStatusApplications(rec["Status Applications"], maxLevel, level, suppressIdx) | ||
local statusRem = formatStatusRemoval(rec["Status Removal"], maxLevel, level) | local statusRem = formatStatusRemoval(rec["Status Removal"], maxLevel, level) | ||
| Line 1,658: | Line 1,745: | ||
end | end | ||
-- Events | |||
local eventsText = formatEvents(rec.Events) | local eventsText = formatEvents(rec.Events) | ||
if eventsText then | if eventsText then | ||
| Line 1,663: | Line 1,751: | ||
end | end | ||
-- Notes | |||
if type(rec.Notes) == "table" and #rec.Notes > 0 then | if type(rec.Notes) == "table" and #rec.Notes > 0 then | ||
addRow(root, "Notes", table.concat(rec.Notes, "<br />"), "sv-row-meta", "Notes") | addRow(root, "Notes", table.concat(rec.Notes, "<br />"), "sv-row-meta", "Notes") | ||