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

Join the Playtest on Steam Now: SpiritVale

Module:GameInfo/Skills: Difference between revisions

From SpiritVale Wiki
No edit summary
Tags: Mobile edit Mobile web edit
No edit summary
Tags: Mobile edit Mobile web edit
 
(8 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:GameInfo/Skills
-- Module:GameInfo/Skills
-- Phase 4.1 Native format rendering (format 1 + format 2, no adapter glue)
-- Phase 4.2 Final native-first rendering (schema 1 + schema 2)
--
--
-- DEFINITIONS POLICY (strict):
-- Notes:
-- - Only accept Definition tokens as JSON strings using \u007C, e.g. "Damage\u007CPiercing".
-- - JSON Definition tokens must be plain strings that decode to Domain|Key.
-- - After jsonDecode, this becomes "Damage|Piercing" and is expanded via {{def|Damage|Piercing}}.
-- - Interactivity is provided by sitewide popup systems in Common.js.
-- - No support for:
-- - Schema 2 is the primary layout target; schema 1 remains supported.
--    * <nowiki>...</nowiki> wrapped |data=
-- - Mechanics / keywords are JSON-driven; the renderer does not infer future
--    * double-escaped pipes (\\u007C)
--  mechanic definition tokens from labels.
--    * template-shaped strings inside JSON (e.g. "{{def|Damage|Piercing}}")
--
-- Icons:
-- - Meta row has its own icon slot, so meta labels always use noicon=1.
-- - Keyword pills also use noicon=1 (no extra icons in pills).
-- - Native-format scaling stat pills render visible short labels/icons,
--  and use a full-pill invisible Definitions hitbox only when active.
--
-- Popups:
-- - Source nodes (.sv-tip-pop / .sv-disclose-pop) are hidden content only.
-- - All interaction is handled by Universal Popups (Common.js). No legacy popup system.
--
-- Requirements / Users (dynamic headers):
-- - Native format accepts requirements/users as maps (title -> items[]).
-- - Titles are treated as dynamic display text (no hardcoded header logic).
-- - Optional __order = ["Title A","Title B", ...] may be provided to force section order.
--  If omitted, ordering is deterministic: (preferred_order first, then remaining keys sorted).
--
-- SITEWIDE CSS ALIGNMENT:
-- - Shared UI ownership lives in Common.css + Citizen.css.
-- - This module keeps semantic GameInfo/Skills hooks, while also opting into
--   shared sitewide classes where the role already matches:
--    * sv-tip / sv-tip-btn
--    * sv-disclose / sv-disclose-btn
--    * sv-tabs / sv-tab / sv-tabpanel
--    * sv-level-* shared slider anatomy
--    * sv-card / sv-tile / sv-pill / sv-hover-lift where appropriate
-- - The CSS pass can now simplify Module:GameInfo/styles.css by leaning on
--  these shared classes instead of duplicating sitewide surface styling.


local p = {}
local p = {}
Line 86: Line 57:


local _CORE_ORDER = {
local _CORE_ORDER = {
{ field = "cost",      label = "Cost"      },
{ field = "cost",      label = "Cost",     def_tok = "Skill|Cost" },
{ field = "cast_time", label = "Cast Time" },
{ field = "cast_time", label = "Cast Time", def_tok = "Skill|CastTime" },
{ field = "cooldown",  label = "Cooldown"  },
{ field = "cooldown",  label = "Cooldown", def_tok = "Skill|Cooldown" },
{ field = "range",    label = "Range"    },
{ field = "range",    label = "Range",     def_tok = "Skill|Range" },
{ field = "area",      label = "Area"      },
{ field = "area",      label = "Area",     def_tok = "Skill|Area" },
{ field = "duration",  label = "Duration"  },
{ field = "duration",  label = "Duration", def_tok = "Skill|Duration" },
}
}


Line 101: Line 72:
end
end


local function _safe_tbl(t) return type(t) == "table" and t or {} end
local function _safe_tbl(t)
return type(t) == "table" and t or {}
end


local function _safe_str(s, fallback)
local function _safe_str(s, fallback)
Line 119: Line 92:
s = s:gsub("[^%w]+", "-"):gsub("%-+", "-"):gsub("^%-", ""):gsub("%-$", "")
s = s:gsub("[^%w]+", "-"):gsub("%-+", "-"):gsub("^%-", ""):gsub("%-$", "")
if s == "" then s = "item" end
if s == "" then s = "item" end
return s
end
local function _humanize_key(s)
s = _trim(s)
if s == "" then return "" end
if s:find("%s") then return s end
s = s:gsub("([A-Z]+)([A-Z][a-z])", "%1 %2")
s = s:gsub("([a-z0-9])([A-Z])", "%1 %2")
return s
return s
end
end
Line 242: Line 224:
s = _trim(s)
s = _trim(s)
return s ~= "" and s:find("|", 1, true) ~= nil
return s ~= "" and s:find("|", 1, true) ~= nil
end
local function _split_def_token(s)
s = _trim(s)
if s == "" then return nil, nil end
local bar = s:find("|", 1, true)
if not bar then return nil, nil end
local domain = _trim(s:sub(1, bar - 1))
local key = _trim(s:sub(bar + 1))
if domain == "" or key == "" then return nil, nil end
return domain, key
end
end


Line 248: Line 244:
if s == "" then return nil end
if s == "" then return nil end


local bar = s:find("|", 1, true)
local domain, key = _split_def_token(s)
if not bar then
if not domain or not key then
return mw.text.nowiki(s)
return mw.text.nowiki(s)
end
end


local domain = _trim(s:sub(1, bar - 1))
local args = { domain, key }
local key    = _trim(s:sub(bar + 1))
if domain == "" or key == "" then
return mw.text.nowiki(s)
end
 
local args = { domain, key }
if noicon then
if noicon then
args.noicon = "1"
args.noicon = "1"
Line 272: Line 262:


return frame:expandTemplate{ title = "def", args = args }
return frame:expandTemplate{ title = "def", args = args }
end
local function _display_text_from_token(s)
s = _trim(s)
if s == "" then return "" end
local _, key = _split_def_token(s)
if key then return _humanize_key(key) end
return s
end
end


Line 292: Line 291:
end
end


local function _split_def_token(s)
local function _build_hitbox(parent, class_name, frame, token)
s = _trim(s)
token = _safe_str(token, "")
if s == "" then return nil, nil end
if not _has_pipe(token) then return false end


local bar = s:find("|", 1, true)
local wt = _render_def_token(frame, token, true, { pill = "1", fill = "1" })
if not bar then return nil, nil end
if not wt then return false end


local domain = _trim(s:sub(1, bar - 1))
parent:tag("span")
local key = _trim(s:sub(bar + 1))
:addClass(class_name)
if domain == "" or key == "" then return nil, nil end
:attr("aria-hidden", "true")
:wikitext(wt)


return domain, key
return true
end
end


local function _keyword_display_text(pill_text)
local function _is_blankish(v)
pill_text = _trim(pill_text)
if v == nil then return true end
if pill_text == "" then return "" end
local s = _trim(v)
return s == "" or s == "—" or s == "-" or s == "--"
end


local domain, key = _split_def_token(pill_text)
local function _value_has_content(val)
if not domain or not key then
if val == nil then return false end
return pill_text
 
if type(val) ~= "table" then
return not _is_blankish(val)
end
end


return key
local series = val.series
end
if type(series) == "table" then
for _, item in ipairs(series) do
if not _is_blankish(item) then
return true
end
end
return false
end


local function _build_keyword_pill(frame, parent, pill_text)
if val.text ~= nil then
pill_text = _trim(pill_text)
return not _is_blankish(val.text)
if pill_text == "" then return end
end


local visible_text = _keyword_display_text(pill_text)
return false
local has_def = _has_pipe(pill_text)
end


local pill = parent:tag("span")
local function _stat_key_from_token(token)
:addClass("sv-pill")
token = _trim(token):lower()
:addClass("sv-pill--value")
if token == "" then return nil end
:addClass("sv-mech-keyword-pill")
return _STAT_TOKEN_TO_KEY[token]
end


pill:tag("span")
local function _normalize_stat_scaling_slots(stat_scaling)
:addClass("sv-mech-keyword-pill__label")
local slots = {}
:wikitext(mw.text.nowiki(visible_text ~= "" and visible_text or pill_text))
for _, key in ipairs(_STAT_ORDER) do
 
slots[key] = nil
if has_def then
local wt = _render_def_token(frame, pill_text, true, { pill = "1", fill = "1" })
if wt then
pill:tag("span")
:addClass("sv-mech-keyword-pill__hit")
:attr("aria-hidden", "true")
:wikitext(wt)
end
end
end
end


local function _is_blankish(v)
for _, it in ipairs(_safe_tbl(stat_scaling)) do
if v == nil then return true end
it = _safe_tbl(it)
local s = _trim(v)
return s == "" or s == "—" or s == "-" or s == "--"
end


local function _value_has_content(val)
local legacy_tok = _safe_str(it.stat_wt or it.stat or "", "")
if val == nil then return false end
if legacy_tok ~= "" then
local key = _stat_key_from_token(legacy_tok)
if key then
slots[key] = it.value
end
end


if type(val) ~= "table" then
for k, v in pairs(it) do
return not _is_blankish(val)
local lk = _trim(k):lower()
end
if _STAT_LABELS[lk] ~= nil then
 
slots[lk] = v
local series = val.series
if type(series) == "table" then
for _, item in ipairs(series) do
if not _is_blankish(item) then
return true
end
end
end
end
return false
end
end


if val.text ~= nil then
return slots
return not _is_blankish(val.text)
end
 
return false
end
end


local function _stat_key_from_token(token)
local function _split_lines(s)
token = _trim(token):lower()
if s == nil then return {} end
if token == "" then return nil end
s = tostring(s)
return _STAT_TOKEN_TO_KEY[token]
s = s:gsub("\r\n", "\n"):gsub("\r", "\n")
if mw.text and mw.text.split then
return mw.text.split(s, "\n", true)
end
local out = {}
for line in (s .. "\n"):gmatch("(.-)\n") do
table.insert(out, line)
end
return out
end
end


local function _normalize_stat_scaling_slots(stat_scaling)
local function _render_notes_body(parent, notes_wt)
local slots = {}
notes_wt = _trim(notes_wt)
for _, key in ipairs(_STAT_ORDER) do
if notes_wt == "" then
slots[key] = nil
parent:wikitext("—")
return
end
end


for _, it in ipairs(_safe_tbl(stat_scaling)) do
notes_wt = notes_wt:gsub("(^|\n)%s+([*#])", "%1%2")
it = _safe_tbl(it)


local legacy_tok = _safe_str(it.stat_wt or it.stat or "", "")
local lines = _split_lines(notes_wt)
if legacy_tok ~= "" then
local i = 1
local key = _stat_key_from_token(legacy_tok)
local wrote_any = false
if key then
slots[key] = it.value
end
end


for k, v in pairs(it) do
local function is_list_line(ln)
local lk = _trim(k):lower()
return type(ln) == "string" and ln:match("^[*#]") ~= nil
if _STAT_LABELS[lk] ~= nil then
slots[lk] = v
end
end
end
end


return slots
while i <= #lines do
end
local ln = lines[i] or ""
if _trim(ln) == "" then
i = i + 1
elseif is_list_line(ln) then
local mark = ln:sub(1, 1)
local tagname = (mark == "#") and "ol" or "ul"


local function _split_lines(s)
local items = {}
if s == nil then return {} end
while i <= #lines do
s = tostring(s)
local l = lines[i] or ""
s = s:gsub("\r\n", "\n"):gsub("\r", "\n")
if _trim(l) == "" then
if mw.text and mw.text.split then
i = i + 1
return mw.text.split(s, "\n", true)
break
end
end
local out = {}
if not l:match("^" .. mark) then break end
for line in (s .. "\n"):gmatch("(.-)\n") do
table.insert(out, line)
end
return out
end


local function _render_notes_body(parent, notes_wt)
local txt = l:gsub("^" .. mark .. "+%s*", "")
notes_wt = _trim(notes_wt)
txt = _trim(txt)
if notes_wt == "" then
if txt ~= "" then table.insert(items, txt) end
parent:wikitext("—")
i = i + 1
return
end
end


notes_wt = notes_wt:gsub("(^|\n)%s+([*#])", "%1%2")
if #items > 0 then
local list = parent:tag(tagname)
for _, txt in ipairs(items) do
list:tag("li"):wikitext(txt)
end
wrote_any = true
end
else
local block = {}
while i <= #lines do
local l = lines[i] or ""
if _trim(l) == "" then
i = i + 1
break
end
if is_list_line(l) then break end
table.insert(block, l)
i = i + 1
end


local lines = _split_lines(notes_wt)
if #block > 0 then
local i = 1
local joined = table.concat(block, "<br />\n")
local wrote_any = false
parent:tag("div"):addClass("sv-note-par"):wikitext(joined)
wrote_any = true
end
end
end


local function is_list_line(ln)
if not wrote_any then
return type(ln) == "string" and ln:match("^[*#]") ~= nil
parent:wikitext(mw.text.nowiki(notes_wt))
end
end
end


while i <= #lines do
local function _build_notes_tip(frame, root_id, notes_wt)
local ln = lines[i] or ""
notes_wt = _trim(notes_wt)
if _trim(ln) == "" then
if notes_wt == "" then return nil end
i = i + 1
elseif is_list_line(ln) then
local mark = ln:sub(1, 1)
local tagname = (mark == "#") and "ol" or "ul"


local items = {}
local tip_id = root_id .. "-notes"
while i <= #lines do
local wrap = mw.html.create("div"):addClass("sv-tip")
local l = lines[i] or ""
if _trim(l) == "" then
i = i + 1
break
end
if not l:match("^" .. mark) then break end


local txt = l:gsub("^" .. mark .. "+%s*", "")
local btn = wrap:tag("span")
txt = _trim(txt)
:addClass("sv-tip-btn")
if txt ~= "" then table.insert(items, txt) end
:addClass("sv-tip-btn--icon")
i = i + 1
:addClass("sv-hover-lift")
end
:attr("role", "button")
:attr("tabindex", "0")
:attr("data-sv-toggle", "1")
:attr("data-sv-pop", "hover")
:attr("data-sv-pop-size", "sm")
:attr("aria-label", "Notes")
:attr("aria-controls", tip_id)
:attr("aria-expanded", "false")


if #items > 0 then
btn:tag("span")
local list = parent:tag(tagname)
:addClass("sv-ico")
for _, txt in ipairs(items) do
:addClass("sv-ico--info")
list:tag("li"):wikitext(txt)
:attr("aria-hidden", "true")
end
:wikitext("i")
wrote_any = true
end
else
local block = {}
while i <= #lines do
local l = lines[i] or ""
if _trim(l) == "" then
i = i + 1
break
end
if is_list_line(l) then break end
table.insert(block, l)
i = i + 1
end


if #block > 0 then
local pop = wrap:tag("div")
local joined = table.concat(block, "<br />\n")
:addClass("sv-tip-pop")
parent:tag("div"):addClass("sv-note-par"):wikitext(joined)
:addClass("sv-hidden")
wrote_any = true
:attr("hidden", "hidden")
end
:attr("id", tip_id)
end
:attr("aria-label", "Notes")
end
 
local head = pop:tag("div"):addClass("sv-tip-pop-head")
head:tag("div"):addClass("sv-tip-pop-title"):wikitext("Notes")
 
local body = pop:tag("div"):addClass("sv-tip-pop-body")
_render_notes_body(body, notes_wt)


if not wrote_any then
return wrap
parent:wikitext(mw.text.nowiki(notes_wt))
end
end
end


local function _build_notes_tip(frame, root_id, notes_wt)
local function _normalize_overcap_sources(sources, total_bonus)
notes_wt = _trim(notes_wt)
sources = _safe_tbl(sources)
if notes_wt == "" then return nil end


local tip_id = root_id .. "-notes"
local out = {}
local wrap = mw.html.create("div"):addClass("sv-tip")
local missing = 0
local explicit_sum = 0


local btn = wrap:tag("span")
for _, s in ipairs(sources) do
:addClass("sv-tip-btn")
local page = ""
:addClass("sv-tip-btn--icon")
local lv = nil
:addClass("sv-hover-lift")
:attr("role", "button")
:attr("tabindex", "0")
:attr("data-sv-toggle", "1")
:attr("data-sv-pop", "hover")
:attr("data-sv-pop-size", "sm")
:attr("aria-label", "Notes")
:attr("aria-controls", tip_id)
:attr("aria-expanded", "false")


btn:tag("span")
if type(s) == "string" then
:addClass("sv-ico")
page = _trim(s)
:addClass("sv-ico--info")
elseif type(s) == "table" then
:attr("aria-hidden", "true")
page = _safe_str(s.page or s[1] or "", "")
:wikitext("i")
lv = _to_int(s.levels or s.level or s[2], nil)
end


local pop = wrap:tag("div")
if page ~= "" then
:addClass("sv-tip-pop")
if lv and lv > 0 then
:addClass("sv-hidden")
explicit_sum = explicit_sum + lv
:attr("hidden", "hidden")
else
:attr("id", tip_id)
lv = nil
:attr("aria-label", "Notes")
missing = missing + 1
end
table.insert(out, { page = page, levels = lv })
end
end


local head = pop:tag("div"):addClass("sv-tip-pop-head")
local remaining = (_to_int(total_bonus, 0) or 0) - explicit_sum
head:tag("div"):addClass("sv-tip-pop-title"):wikitext("Notes")
if remaining < 0 then remaining = 0 end


local body = pop:tag("div"):addClass("sv-tip-pop-body")
if missing == 1 and remaining > 0 then
_render_notes_body(body, notes_wt)
for _, it in ipairs(out) do
if it.levels == nil then
it.levels = remaining
break
end
end
end


return wrap
return out
end
end


local function _normalize_overcap_sources(sources, total_bonus)
local function _build_overcap_tip(root_id, default_cap, max_cap, sources)
sources = _safe_tbl(sources)
default_cap = _to_int(default_cap, 1) or 1
max_cap = _to_int(max_cap, default_cap) or default_cap
if max_cap <= default_cap then return nil end


local out = {}
local total = max_cap - default_cap
local missing = 0
local tip_id = root_id .. "-overcap"
local explicit_sum = 0


for _, s in ipairs(sources) do
local norm = _normalize_overcap_sources(sources, total)
local page = ""
local show_unknown = (#norm == 0)
local lv = nil


if type(s) == "string" then
local wrap = mw.html.create("span")
page = _trim(s)
:addClass("sv-tip")
elseif type(s) == "table" then
:addClass("sv-overcap-tip")
page = _safe_str(s.page or s[1] or "", "")
lv = _to_int(s.levels or s.level or s[2], nil)
end


if page ~= "" then
local btn = wrap:tag("span")
if lv and lv > 0 then
:addClass("sv-tip-btn")
explicit_sum = explicit_sum + lv
:addClass("sv-tip-btn--pill")
else
:addClass("sv-overcap-btn")
lv = nil
:addClass("sv-hover-lift")
missing = missing + 1
:attr("role", "button")
end
:attr("tabindex", "0")
table.insert(out, { page = page, levels = lv })
:attr("data-sv-toggle", "1")
end
:attr("data-sv-pop", "click")
end
:attr("data-sv-pop-size", "sm")
:attr("aria-label", "Overcap")
:attr("aria-controls", tip_id)
:attr("aria-expanded", "false")
:wikitext(mw.text.nowiki("Overcap +" .. tostring(total)))


local remaining = (_to_int(total_bonus, 0) or 0) - explicit_sum
local pop = wrap:tag("div")
if remaining < 0 then remaining = 0 end
:addClass("sv-tip-pop")
:addClass("sv-hidden")
:attr("hidden", "hidden")
:attr("id", tip_id)
:attr("aria-label", "Overcap")


if missing == 1 and remaining > 0 then
local head = pop:tag("div"):addClass("sv-tip-pop-head")
for _, it in ipairs(out) do
head:tag("div"):addClass("sv-tip-pop-title"):wikitext("Overcap sources")
if it.levels == nil then
it.levels = remaining
break
end
end
end


return out
local body = pop:tag("div"):addClass("sv-tip-pop-body")
end
body:tag("div"):addClass("sv-overcap-summary"):wikitext(mw.text.nowiki(
"Raises max from " .. tostring(default_cap) .. " to " .. tostring(max_cap) .. " (+" .. tostring(total) .. ")."
))


local function _build_overcap_tip(root_id, default_cap, max_cap, sources)
local ul = body:tag("ul"):addClass("sv-disclose-list")
default_cap = _to_int(default_cap, 1) or 1
max_cap = _to_int(max_cap, default_cap) or default_cap
if max_cap <= default_cap then return nil end


local total = max_cap - default_cap
if show_unknown then
local tip_id = root_id .. "-overcap"
ul:tag("li"):wikitext("Source unknown")
else
for _, it in ipairs(norm) do
local li = ul:tag("li")
local page = _safe_str(it.page, "")
if page ~= "" then
li:wikitext(string.format("[[%s|%s]]", page, mw.text.nowiki(page)))
else
li:wikitext("")
end


local norm = _normalize_overcap_sources(sources, total)
if it.levels ~= nil and it.levels > 0 then
local show_unknown = (#norm == 0)
li:wikitext(mw.text.nowiki(" (+" .. tostring(it.levels) .. ")"))
else
li:wikitext(mw.text.nowiki(" (+?)"))
end
end
end
 
return wrap
end


local wrap = mw.html.create("span")
local function _normalize_disclose_item(it)
:addClass("sv-tip")
if type(it) == "string" then
:addClass("sv-overcap-tip")
local s = _trim(it)
if s == "" then return nil end
return { text = s, page = s }
end


local btn = wrap:tag("span")
if type(it) ~= "table" then return nil end
:addClass("sv-tip-btn")
:addClass("sv-tip-btn--pill")
:addClass("sv-overcap-btn")
:addClass("sv-hover-lift")
:attr("role", "button")
:attr("tabindex", "0")
:attr("data-sv-toggle", "1")
:attr("data-sv-pop", "click")
:attr("data-sv-pop-size", "sm")
:attr("aria-label", "Overcap")
:attr("aria-controls", tip_id)
:attr("aria-expanded", "false")
:wikitext(mw.text.nowiki("Overcap +" .. tostring(total)))


local pop = wrap:tag("div")
if it.text ~= nil or it.page ~= nil or it.suffix ~= nil or it.name ~= nil or it.label ~= nil then
:addClass("sv-tip-pop")
local text = _safe_str(it.text or it.label or it.name or "", "")
:addClass("sv-hidden")
if text == "" then return nil end
:attr("hidden", "hidden")
local page = _safe_str(it.page or "", "")
:attr("id", tip_id)
if page == "" then page = text end
:attr("aria-label", "Overcap")
local suffix = _safe_str(it.suffix or "", "")
return { text = text, page = (page ~= "" and page or nil), suffix = (suffix ~= "" and suffix or nil) }
end


local head = pop:tag("div"):addClass("sv-tip-pop-head")
if #it >= 1 then
head:tag("div"):addClass("sv-tip-pop-title"):wikitext("Overcap sources")
local name = _safe_str(it[1], "")
 
if name == "" then return nil end
local body = pop:tag("div"):addClass("sv-tip-pop-body")
local suffix = _safe_str(it[2], "")
body:tag("div"):addClass("sv-overcap-summary"):wikitext(mw.text.nowiki(
return {
"Raises max from " .. tostring(default_cap) .. " to " .. tostring(max_cap) .. " (+" .. tostring(total) .. ")."
text = name,
))
page = name,
suffix = (suffix ~= "" and suffix or nil)
}
end


local ul = body:tag("ul"):addClass("sv-disclose-list")
local lines = _safe_tbl(it.label_lines)
local text = ""
for _, ln in ipairs(lines) do
local s = _trim(ln)
if s ~= "" then
if text ~= "" then text = text .. " " end
text = text .. s
end
end
if text == "" then return nil end


if show_unknown then
local link = _safe_tbl(it.link)
ul:tag("li"):wikitext("Source unknown")
local page = _safe_str(link.page or it.page or "", "")
else
return { text = text, page = (page ~= "" and page or nil) }
for _, it in ipairs(norm) do
end
local li = ul:tag("li")
local page = _safe_str(it.page, "")
if page ~= "" then
li:wikitext(string.format("[[%s|%s]]", page, mw.text.nowiki(page)))
else
li:wikitext("")
end


if it.levels ~= nil and it.levels > 0 then
local function _count_group_items(groups)
li:wikitext(mw.text.nowiki(" (+" .. tostring(it.levels) .. ")"))
local n = 0
else
for _, g in ipairs(_safe_tbl(groups)) do
li:wikitext(mw.text.nowiki(" (+?)"))
for _, it in ipairs(_safe_tbl(g.items)) do
end
if _normalize_disclose_item(it) then n = n + 1 end
end
end
end
end
 
return n
return wrap
end
end


local function _normalize_disclose_item(it)
local function _build_grouped_disclose(root_id, key, label, groups)
if type(it) == "string" then
local total = _count_group_items(groups)
local s = _trim(it)
if total == 0 then return nil, 0 end
if s == "" then return nil end
return { text = s }
end


if type(it) ~= "table" then return nil end
local pop_id = root_id .. "-" .. key
local wrap = mw.html.create("div"):addClass("sv-disclose")


if it.text ~= nil or it.page ~= nil or it.suffix ~= nil or it.name ~= nil or it.label ~= nil then
local btn = wrap:tag("span")
local text = _safe_str(it.text or it.label or it.name or "", "")
:addClass("sv-disclose-btn")
if text == "" then return nil end
:addClass("sv-disclose-btn--compact")
local page = _safe_str(it.page or "", "")
:addClass("sv-hover-lift")
local suffix = _safe_str(it.suffix or "", "")
:attr("role", "button")
return { text = text, page = (page ~= "" and page or nil), suffix = (suffix ~= "" and suffix or nil) }
:attr("tabindex", "0")
end
:attr("data-sv-toggle", "1")
 
:attr("data-sv-pop", "click")
if #it >= 1 then
:attr("data-sv-pop-size", "sm")
local name = _safe_str(it[1], "")
:attr("aria-label", label)
if name == "" then return nil end
:attr("aria-controls", pop_id)
local suffix = _safe_str(it[2], "")
:attr("aria-expanded", "false")
return {
text = name,
page = name,
suffix = (suffix ~= "" and suffix or nil)
}
end


local lines = _safe_tbl(it.label_lines)
btn:tag("span"):addClass("sv-disclose-label"):wikitext(mw.text.nowiki(label))
local text = ""
btn:tag("span"):addClass("sv-disclose-count"):wikitext(" (" .. tostring(total) .. ")")
for _, ln in ipairs(lines) do
local s = _trim(ln)
if s ~= "" then
if text ~= "" then text = text .. " " end
text = text .. s
end
end
if text == "" then return nil end


local link = _safe_tbl(it.link)
local pop = wrap:tag("div")
local page = _safe_str(link.page or it.page or "", "")
:addClass("sv-disclose-pop")
return { text = text, page = (page ~= "" and page or nil) }
:addClass("sv-hidden")
end
:attr("hidden", "hidden")
:attr("id", pop_id)
:attr("aria-label", label)


local function _count_group_items(groups)
local head = pop:tag("div"):addClass("sv-disclose-pop-head")
local n = 0
head:tag("div"):addClass("sv-disclose-pop-title"):wikitext(mw.text.nowiki(label))
for _, g in ipairs(_safe_tbl(groups)) do
for _, it in ipairs(_safe_tbl(g.items)) do
if _normalize_disclose_item(it) then n = n + 1 end
end
end
return n
end


local function _build_grouped_disclose(root_id, key, label, groups)
local body = pop:tag("div"):addClass("sv-disclose-pop-body")
local total = _count_group_items(groups)
local secs = body:tag("div"):addClass("sv-disclose-sections")
if total == 0 then return nil, 0 end


local pop_id = root_id .. "-" .. key
local function render_item(parent, norm)
local wrap = mw.html.create("div"):addClass("sv-disclose")
local item = parent:tag("span"):addClass("sv-disclose-item")


local btn = wrap:tag("span")
if norm.page then
:addClass("sv-disclose-btn")
item:wikitext(string.format(
:addClass("sv-disclose-btn--compact")
"[[%s|%s]]",
:addClass("sv-hover-lift")
norm.page,
:attr("role", "button")
mw.text.nowiki(norm.text)
:attr("tabindex", "0")
))
:attr("data-sv-toggle", "1")
else
:attr("data-sv-pop", "click")
item:wikitext(mw.text.nowiki(norm.text))
:attr("data-sv-pop-size", "sm")
end
:attr("aria-label", label)
:attr("aria-controls", pop_id)
:attr("aria-expanded", "false")


btn:tag("span"):addClass("sv-disclose-label"):wikitext(mw.text.nowiki(label))
local sfx = _safe_str(norm.suffix or "", "")
btn:tag("span"):addClass("sv-disclose-count"):wikitext(" (" .. tostring(total) .. ")")
if sfx ~= "" then
if not sfx:match("^%s") then sfx = " " .. sfx end
item:tag("span"):addClass("sv-disclose-sfx"):wikitext(mw.text.nowiki(sfx))
end
end


local pop = wrap:tag("div")
for _, g in ipairs(_safe_tbl(groups)) do
:addClass("sv-disclose-pop")
local title = _safe_str(g.title, "")
:addClass("sv-hidden")
local items = _safe_tbl(g.items)
:attr("hidden", "hidden")
:attr("id", pop_id)
:attr("aria-label", label)


local head = pop:tag("div"):addClass("sv-disclose-pop-head")
if #items > 0 then
head:tag("div"):addClass("sv-disclose-pop-title"):wikitext(mw.text.nowiki(label))
local sec = secs:tag("div"):addClass("sv-disclose-sec")
sec:tag("div"):addClass("sv-disclose-sec-title"):wikitext(mw.text.nowiki(title))


local body = pop:tag("div"):addClass("sv-disclose-pop-body")
local line = sec:tag("div"):addClass("sv-disclose-sec-items")
local secs = body:tag("div"):addClass("sv-disclose-sections")


local function render_item(parent, norm)
local norms = {}
local item = parent:tag("span"):addClass("sv-disclose-item")
for _, it in ipairs(items) do
local norm = _normalize_disclose_item(it)
if norm then table.insert(norms, norm) end
end


if norm.page then
if #norms == 0 then
item:wikitext(string.format(
line:tag("span"):addClass("sv-disclose-item"):wikitext("")
"[[%s|%s]]",
else
norm.page,
for i, norm in ipairs(norms) do
mw.text.nowiki(norm.text)
render_item(line, norm)
))
if i < #norms then line:wikitext(", ") end
else
end
item:wikitext(mw.text.nowiki(norm.text))
end
end
 
local sfx = _safe_str(norm.suffix or "", "")
if sfx ~= "" then
if not sfx:match("^%s") then sfx = " " .. sfx end
item:tag("span"):addClass("sv-disclose-sfx"):wikitext(mw.text.nowiki(sfx))
end
end
end
end


for _, g in ipairs(_safe_tbl(groups)) do
return wrap, total
local title = _safe_str(g.title, "—")
end
local items = _safe_tbl(g.items)


if #items > 0 then
local function _build_identity(frame, root_id, identity, notes_wt)
local sec = secs:tag("div"):addClass("sv-disclose-sec")
identity = _safe_tbl(identity)
sec:tag("div"):addClass("sv-disclose-sec-title"):wikitext(mw.text.nowiki(title))
local root = mw.html.create("div"):addClass("sv-skill-head")


local line = sec:tag("div"):addClass("sv-disclose-sec-items")
local icon_div = root:tag("div")
:addClass("sv-skill-icon")
:addClass("sv-tile")


local norms = {}
local sprite = _safe_tbl(identity.sprite)
for _, it in ipairs(items) do
local sprite_page = _safe_str(sprite.page, "")
local norm = _normalize_disclose_item(it)
local sprite_alt = _safe_str(sprite.alt, _safe_str(identity.display_name, ""))
if norm then table.insert(norms, norm) end
end


if #norms == 0 then
if sprite_page ~= "" then
line:tag("span"):addClass("sv-disclose-item"):wikitext("—")
icon_div:node(_render_file_image(sprite_page, sprite_alt, 64))
else
else
for i, norm in ipairs(norms) do
icon_div:node(_question_badge())
render_item(line, norm)
if i < #norms then line:wikitext(", ") end
end
end
end
end
end


return wrap, total
local headtext = root:tag("div"):addClass("sv-skill-headtext")
end


local function _build_identity(frame, root_id, identity, notes_wt)
local title_row = headtext:tag("div"):addClass("sv-skill-title-row")
identity = _safe_tbl(identity)
title_row:tag("div")
local root = mw.html.create("div"):addClass("sv-skill-head")
:addClass("sv-skill-title")
:wikitext(mw.text.nowiki(_safe_str(identity.display_name, "Unknown Skill")))


local icon_div = root:tag("div")
local tip = _build_notes_tip(frame, root_id, notes_wt)
:addClass("sv-skill-icon")
if tip then title_row:node(tip) end
:addClass("sv-tile")


local sprite = _safe_tbl(identity.sprite)
headtext:tag("div")
local sprite_page = _safe_str(sprite.page, "")
:addClass("sv-skill-desc")
local sprite_alt = _safe_str(sprite.alt, _safe_str(identity.display_name, ""))
:wikitext(mw.text.nowiki(_safe_str(identity.description, "")))
 
return root
end
 
local function _build_meta_card(frame, parent, spec)
spec = _safe_tbl(spec)
 
local card = parent:tag("div")
:addClass("sv-tile")
:addClass("sv-meta-card")
:addClass("sv-hover-lift")
 
local icon_div = card:tag("div")
:addClass("sv-meta-icon")
:addClass("sv-tile")


if sprite_page ~= "" then
local icon_page = _safe_str(spec.icon_page, "")
icon_div:node(_render_file_image(sprite_page, sprite_alt, 64))
local icon_alt = _safe_str(spec.icon_alt, "")
if icon_page ~= "" then
icon_div:node(_render_file_image(icon_page, icon_alt, 24))
else
else
icon_div:node(_question_badge())
icon_div:node(_question_badge())
end
end


local headtext = root:tag("div"):addClass("sv-skill-headtext")
local wrap = card:tag("div"):addClass("sv-meta-textwrap")
local text = wrap:tag("div"):addClass("sv-meta-text")


local title_row = headtext:tag("div"):addClass("sv-skill-title-row")
local lines = _safe_tbl(spec.lines)
title_row:tag("div")
if #lines > 0 then
:addClass("sv-skill-title")
text:node(_meta_lines_def(frame, lines))
:wikitext(mw.text.nowiki(_safe_str(identity.display_name, "Unknown Skill")))
else
text:wikitext("")
end


local tip = _build_notes_tip(frame, root_id, notes_wt)
_build_hitbox(card, "sv-meta-hit", frame, spec.hit_tok)
if tip then title_row:node(tip) end
end
 
headtext:tag("div")
:addClass("sv-skill-desc")
:wikitext(mw.text.nowiki(_safe_str(identity.description, "")))
 
return root
end


local function _build_meta_row(frame, meta_row)
local function _build_meta_row(frame, meta_row)
Line 833: Line 828:
for i = 1, 4 do
for i = 1, 4 do
local cell = _safe_tbl(meta_row[i])
local cell = _safe_tbl(meta_row[i])
local card = meta:tag("div")
:addClass("sv-meta-card")
:addClass("sv-hover-lift")
local icon = _safe_tbl(cell.icon)
local icon = _safe_tbl(cell.icon)
local icon_page = _safe_str(icon.page, "")
local icon_alt = _safe_str(icon.alt, "")
local icon_div = card:tag("div")
:addClass("sv-meta-icon")
:addClass("sv-tile")
if icon_page ~= "" then
icon_div:node(_render_file_image(icon_page, icon_alt, 24))
else
icon_div:node(_question_badge())
end
local wrap = card:tag("div"):addClass("sv-meta-textwrap")
local text = wrap:tag("div"):addClass("sv-meta-text")
local lines = _safe_tbl(cell.label_lines)
local lines = _safe_tbl(cell.label_lines)
if #lines > 0 then
local hit_tok = ""
text:node(_meta_lines_def(frame, lines))
else
text:wikitext("")
end


local hit_tok = ""
for _, ln in ipairs(lines) do
for _, ln in ipairs(lines) do
local s = _safe_str(ln, "")
local s = _safe_str(ln, "")
Line 869: Line 839:
end
end
end
end
if hit_tok ~= "" then
 
local wt = _render_def_token(frame, hit_tok, true, { pill = "1", fill = "1" })
_build_meta_card(frame, meta, {
if wt then
icon_page = _safe_str(icon.page, ""),
card:tag("span")
icon_alt = _safe_str(icon.alt, ""),
:addClass("sv-meta-hit")
lines = lines,
:attr("aria-hidden", "true")
hit_tok = hit_tok,
:wikitext(wt)
})
end
end
end
end


Line 889: Line 857:
for i = 1, 4 do
for i = 1, 4 do
local cell = _safe_tbl(skill_meta[i])
local cell = _safe_tbl(skill_meta[i])
local card = meta:tag("div")
local lines = _safe_tbl(cell.label_lines)
:addClass("sv-meta-card")
local hit_tok = _safe_str(cell.label_wt, "")
:addClass("sv-hover-lift")


local icon_div = card:tag("div")
if #lines == 0 and hit_tok ~= "" then
:addClass("sv-meta-icon")
lines = { hit_tok }
:addClass("sv-tile")
 
local icon_page = _safe_str(cell.icon, "")
if icon_page ~= "" then
icon_div:node(_render_file_image(icon_page, "", 24))
else
icon_div:node(_question_badge())
end
end


local wrap = card:tag("div"):addClass("sv-meta-textwrap")
_build_meta_card(frame, meta, {
local text = wrap:tag("div"):addClass("sv-meta-text")
icon_page = _safe_str(cell.icon, ""),
 
icon_alt = "",
local display_lines = _safe_tbl(cell.label_lines)
lines = lines,
if #display_lines == 0 then
hit_tok = hit_tok,
local label_wt = _safe_str(cell.label_wt, "")
})
if label_wt ~= "" then display_lines = { label_wt } end
end
 
if #display_lines > 0 then
text:node(_meta_lines_def(frame, display_lines))
else
text:wikitext("—")
end
 
local hit_tok = _safe_str(cell.label_wt, "")
if _has_pipe(hit_tok) then
local wt = _render_def_token(frame, hit_tok, true, { pill = "1", fill = "1" })
if wt then
card:tag("span")
:addClass("sv-meta-hit")
:attr("aria-hidden", "true")
:wikitext(wt)
end
end
end
end


Line 1,055: Line 996:
if tok ~= "" and val ~= "" then
if tok ~= "" and val ~= "" then
local item = list:tag("div")
local item = list:tag("div")
:addClass("sv-tile")
:addClass("sv-scaling-item")
:addClass("sv-scaling-item")
:addClass("sv-scaling-item--stat")
:addClass("sv-scaling-item--stat")
Line 1,064: Line 1,006:


for _, ln in ipairs(_safe_tbl(scaling.scaling_lines)) do
for _, ln in ipairs(_safe_tbl(scaling.scaling_lines)) do
local item = list:tag("div"):addClass("sv-scaling-item")
local item = list:tag("div")
:addClass("sv-tile")
:addClass("sv-scaling-item")
_apply_value(item, ln, level)
_apply_value(item, ln, level)
end
end
Line 1,113: Line 1,057:


local pill = parent:tag("div")
local pill = parent:tag("div")
:addClass("sv-pill")
:addClass("sv-pill--compact")
:addClass("sv_skill_scaling__pill")
:addClass("sv_skill_scaling__pill")
:addClass("sv_skill_scaling__pill--stat")
:addClass("sv_skill_scaling__pill--stat")
Line 1,121: Line 1,067:


pill:addClass(active and "is-active" or "is-inactive")
pill:addClass(active and "is-active" or "is-inactive")
if active then pill:addClass("sv-hover-lift") end


local main = pill:tag("div"):addClass("sv_skill_scaling__stat-main")
local main = pill:tag("div"):addClass("sv_skill_scaling__stat-main")
Line 1,138: Line 1,085:


if active then
if active then
local wt = _render_def_token(frame, def_tok, false, { pill = "1", fill = "1" })
_build_hitbox(pill, "sv_skill_scaling__stat-hit", frame, def_tok)
if wt then
pill:tag("span")
:addClass("sv_skill_scaling__stat-hit")
:attr("aria-hidden", "true")
:wikitext(wt)
end
end
end
end
end


local function _build_skill_scaling_core_pill(parent, spec, fld, level)
local function _build_skill_scaling_core_pill(frame, parent, spec, fld, level)
fld = _safe_tbl(fld)
fld = _safe_tbl(fld)
local active = _value_has_content(fld.value)
local active = _value_has_content(fld.value)
local resolved_label = _safe_str(fld.label or fld.key, spec.label)
local resolved_tok = _safe_str(fld.label_wt or fld.def_wt, spec.def_tok)


local pill = parent:tag("div")
local pill = parent:tag("div")
Line 1,158: Line 1,101:
:addClass("sv_skill_scaling__core-pill")
:addClass("sv_skill_scaling__core-pill")
:addClass("sv_skill_scaling__core-pill--" .. spec.field)
:addClass("sv_skill_scaling__core-pill--" .. spec.field)
:addClass("sv-hover-lift")
:attr("data-core-key", spec.field)
:attr("data-core-key", spec.field)
:attr("aria-label", resolved_label)


pill:addClass(active and "is-active" or "is-inactive")
pill:addClass(active and "is-active" or "is-inactive")


local top = pill:tag("div"):addClass("sv_skill_scaling__core-main")
local top = pill:tag("div"):addClass("sv_skill_scaling__core-main")
local value = top:tag("span"):addClass("sv_skill_scaling__core-value")
local value = top:tag("span"):addClass("sv_skill_scaling__core-value")
_apply_value(value, fld.value, level)
_apply_value(value, fld.value, level)
Line 1,176: Line 1,120:
pill:tag("div")
pill:tag("div")
:addClass("sv_skill_scaling__core-label")
:addClass("sv_skill_scaling__core-label")
:wikitext(mw.text.nowiki(spec.label))
:wikitext(mw.text.nowiki(resolved_label))
 
_build_hitbox(pill, "sv_skill_scaling__core-hit", frame, resolved_tok)
end
end


Line 1,197: Line 1,143:


local body = root:tag("div"):addClass("sv_skill_scaling__body")
local body = root:tag("div"):addClass("sv_skill_scaling__body")
local cluster = body:tag("div"):addClass("sv_skill_scaling__cluster")


local damage = _safe_tbl(skill_scaling.damage)
local damage = _safe_tbl(skill_scaling.damage)
Line 1,205: Line 1,149:


do
do
local primary = cluster:tag("div")
local primary_group = body:tag("div")
:addClass("sv_skill_scaling__column")
:addClass("sv_skill_scaling__group")
:addClass("sv_skill_scaling__column--primary")
:addClass("sv_skill_scaling__group--primary-stats")
:addClass("sv_skill_scaling__primary")


local v = primary:tag("div"):addClass("sv_skill_scaling__primary-value")
local cluster = primary_group:tag("div"):addClass("sv_skill_scaling__cluster")
_apply_value(v, damage_value, actual_default)


primary:tag("div")
do
:addClass("sv_skill_scaling__primary-label")
local primary = cluster:tag("div")
:wikitext(mw.text.nowiki(damage_label))
:addClass("sv_skill_scaling__column")
end
:addClass("sv_skill_scaling__column--primary")
 
:addClass("sv_skill_scaling__primary")
do
local stats_col = cluster:tag("div")
:addClass("sv_skill_scaling__column")
:addClass("sv_skill_scaling__column--stats")
:addClass("sv_skill_scaling__stats")


local stats_grid = stats_col:tag("div"):addClass("sv_skill_scaling__stats-grid")
local v = primary:tag("div"):addClass("sv_skill_scaling__primary-value")
local stat_slots = _normalize_stat_scaling_slots(skill_scaling.stat_scaling)
_apply_value(v, damage_value, actual_default)


for _, stat_key in ipairs(_STAT_GRID_ORDER) do
primary:tag("div")
_build_skill_scaling_stat_pill(frame, stats_grid, stat_key, stat_slots[stat_key], actual_default)
:addClass("sv_skill_scaling__primary-label")
:wikitext(mw.text.nowiki(damage_label))
end
end
end


body:tag("div"):addClass("sv_skill_scaling__side")
do
local stats_col = cluster:tag("div")
:addClass("sv_skill_scaling__column")
:addClass("sv_skill_scaling__column--stats")
:addClass("sv_skill_scaling__stats")
 
local stats_grid = stats_col:tag("div"):addClass("sv_skill_scaling__stats-grid")
local stat_slots = _normalize_stat_scaling_slots(skill_scaling.stat_scaling)
 
for _, stat_key in ipairs(_STAT_GRID_ORDER) do
_build_skill_scaling_stat_pill(frame, stats_grid, stat_key, stat_slots[stat_key], actual_default)
end
end
end


do
do
local core_col = body:tag("div")
local core_group = body:tag("div")
:addClass("sv_skill_scaling__group")
:addClass("sv_skill_scaling__group--core")
 
local core_col = core_group:tag("div")
:addClass("sv_skill_scaling__column")
:addClass("sv_skill_scaling__column")
:addClass("sv_skill_scaling__column--core")
:addClass("sv_skill_scaling__column--core")
Line 1,243: Line 1,197:


for _, spec in ipairs(_CORE_ORDER) do
for _, spec in ipairs(_CORE_ORDER) do
_build_skill_scaling_core_pill(core_grid, spec, skill_scaling[spec.field], actual_default)
_build_skill_scaling_core_pill(frame, core_grid, spec, skill_scaling[spec.field], actual_default)
end
end
end
end
Line 1,250: Line 1,204:
end
end


local function _build_tabs(frame, root_id, tabs_obj, level)
local function _normalize_modifier_spec(item)
tabs_obj = _safe_tbl(tabs_obj)
item = _safe_tbl(item)
local explicit_tok = _safe_str(item.label_wt or item.def_wt, "")
local label_text = _safe_str(item.label or item.key or item.text, "")
if label_text == "" and explicit_tok ~= "" then
label_text = _display_text_from_token(explicit_tok)
end
return {
label_tok = explicit_tok,
label_text = label_text,
value = item.value,
}
end
 
local function _build_modifier_pill(frame, parent, item, level)
local spec = _normalize_modifier_spec(item)
if spec.label_text == "" then return end


local mechanics = _safe_tbl(tabs_obj.mechanics)
local has_def = _has_pipe(spec.label_tok)
local effects  = _safe_tbl(tabs_obj.effects)
local has_value = _value_has_content(spec.value)


local mechanics_mods = _safe_tbl(mechanics.mods)
local pill = parent:tag("div")
local mechanics_keywords = _safe_tbl(mechanics.keywords)
:addClass("sv-tile")
local effects_cards = _safe_tbl(effects.cards)
:addClass("sv-mech-mod-pill")
:attr("aria-label", spec.label_text)


if #mechanics_mods == 0 then
if has_value then
mechanics_mods = _safe_tbl(mechanics.grid)
pill:addClass("is-active")
else
pill:addClass("is-inactive")
end
end
if #mechanics_keywords == 0 then
 
local old_keywords = _safe_tbl(tabs_obj.keywords)
if has_def or has_value then
mechanics_keywords = _safe_tbl(old_keywords.pills)
pill:addClass("sv-hover-lift")
end
end
if #effects_cards == 0 then
local old_events = _safe_tbl(tabs_obj.events)
local merged = {}


for _, card in ipairs(_safe_tbl(effects.cards)) do
pill:tag("div")
table.insert(merged, card)
:addClass("sv-mech-mod-pill__label")
end
:wikitext(mw.text.nowiki(spec.label_text))
for _, card in ipairs(_safe_tbl(old_events.cards)) do
 
table.insert(merged, card)
local value = pill:tag("div"):addClass("sv-mech-mod-pill__value")
_apply_value(value, spec.value, level)
 
if has_def then
_build_hitbox(pill, "sv-mech-mod-pill__hit", frame, spec.label_tok)
end
end
 
local function _normalize_keyword_spec(item)
if type(item) == "string" then
local tok = _safe_str(item, "")
local label = tok
if _has_pipe(tok) then
label = _display_text_from_token(tok)
end
end
return {
label_tok = (_has_pipe(tok) and tok or ""),
label_text = label,
}
end


effects_cards = merged
item = _safe_tbl(item)
local tok = _safe_str(item.label_wt or item.def_wt, "")
local label = _safe_str(item.label or item.text or item.key, "")
if label == "" and tok ~= "" then
label = _display_text_from_token(tok)
end
end
return {
label_tok = tok,
label_text = label,
}
end


local mechanics_count = _to_int(
local function _build_keyword_pill(frame, parent, item)
mechanics.count,
local spec = _normalize_keyword_spec(item)
#mechanics_mods + #mechanics_keywords
local display_text = _safe_str(spec.label_text, "")
)
local hit_tok = _safe_str(spec.label_tok, "")
if display_text == "" then return end


local effects_count = _to_int(
local has_def = _has_pipe(hit_tok)
effects.count,
local pill = parent:tag("span")
#effects_cards
:addClass("sv-pill")
)
:addClass("sv-pill--value")
:addClass("sv-mech-keyword-pill")


local tab_specs = {
if has_def then pill:addClass("sv-hover-lift") end
{ key = "mechanics", title = "Mechanics (" .. tostring(mechanics_count) .. ")" },
{ key = "effects",  title = "Effects (" .. tostring(effects_count) .. ")" },
}


local root = mw.html.create("div"):addClass("sv-skill-tabs")
pill:tag("span")
:addClass("sv-mech-keyword-pill__label")
:wikitext(mw.text.nowiki(display_text))


local tabs = root:tag("div")
if has_def then
:addClass("sv-tabs")
_build_hitbox(pill, "sv-mech-keyword-pill__hit", frame, hit_tok)
:attr("data-tabs", "1")
end
:attr("data-tabs-root", root_id)
end


local list = tabs:tag("div"):addClass("sv-tabs-list"):attr("role", "tablist")
local function _build_tabs(frame, root_id, tabs_obj, level)
local panels = tabs:tag("div"):addClass("sv-tabs-panels")
tabs_obj = _safe_tbl(tabs_obj)


for i, spec in ipairs(tab_specs) do
local mechanics = _safe_tbl(tabs_obj.mechanics)
local active = (i == 1)
local effects  = _safe_tbl(tabs_obj.effects)
 
local mechanics_mods = _safe_tbl(mechanics.mods)
local mechanics_keywords = _safe_tbl(mechanics.keywords)
local effects_cards = _safe_tbl(effects.cards)


list:tag("span")
if #mechanics_mods == 0 then
:addClass("sv-tab")
mechanics_mods = _safe_tbl(mechanics.grid)
:attr("role", "tab")
end
:attr("tabindex", active and "0" or "-1")
if #mechanics_keywords == 0 then
:attr("data-tab", spec.key)
local old_keywords = _safe_tbl(tabs_obj.keywords)
:attr("aria-selected", active and "true" or "false")
mechanics_keywords = _safe_tbl(old_keywords.pills)
:wikitext(mw.text.nowiki(spec.title))
end
end
if #effects_cards == 0 then
local old_events = _safe_tbl(tabs_obj.events)
local merged = {}


local function render_ref_icon(parent, icon)
for _, card in ipairs(_safe_tbl(effects.cards)) do
icon = _safe_tbl(icon)
table.insert(merged, card)
local page = _safe_str(icon.page, "")
end
local alt = _safe_str(icon.alt, "")
for _, card in ipairs(_safe_tbl(old_events.cards)) do
if page ~= "" then
table.insert(merged, card)
parent:node(_render_file_image(page, alt, 52))
else
parent:node(_question_badge())
end
end
effects_cards = merged
end
end


local function render_effect_stats(parent, stats, kind)
local mechanics_count = _to_int(
stats = _safe_tbl(stats)
mechanics.count,
kind = _safe_str(kind, ""):lower()
#mechanics_mods + #mechanics_keywords
)


if kind == "event" then
local effects_count = _to_int(
local sub = parent:tag("div"):addClass("sv-ref-sub")
effects.count,
#effects_cards
)


if stats.text ~= nil then
local tab_specs = {
_apply_value(sub, stats, level)
{ key = "mechanics", title = "Mechanics (" .. tostring(mechanics_count) .. ")" },
else
{ key = "effects",   title = "Effects (" .. tostring(effects_count) .. ")" },
_apply_value(sub, { text = "" }, level)
}
end
 
return
local root = mw.html.create("div"):addClass("sv-skill-tabs")
end


if next(stats) ~= nil then
local tabs = root:tag("div")
local srow = parent:tag("div"):addClass("sv-ref-stats")
:addClass("sv-tabs")
:attr("data-tabs", "1")
:attr("data-tabs-root", root_id)


local d = srow:tag("span")
local list = tabs:tag("div"):addClass("sv-tabs-list"):attr("role", "tablist")
:addClass("sv-pill")
local panels = tabs:tag("div"):addClass("sv-tabs-panels")
:addClass("sv-pill--value")
:addClass("sv-ref-stat")
_apply_value(d, stats.duration, level)


local c = srow:tag("span")
for i, spec in ipairs(tab_specs) do
:addClass("sv-pill")
local active = (i == 1)
:addClass("sv-pill--value")
:addClass("sv-ref-stat")
_apply_value(c, stats.chance, level)


local st = srow:tag("span")
list:tag("span")
:addClass("sv-pill")
:addClass("sv-tab")
:addClass("sv-pill--value")
:attr("role", "tab")
:addClass("sv-ref-stat")
:attr("tabindex", active and "0" or "-1")
_apply_value(st, stats.stacks, level)
:attr("data-tab", spec.key)
end
:attr("aria-selected", active and "true" or "false")
:wikitext(mw.text.nowiki(spec.title))
end
end


local function render_ref_card(parent, card)
local function render_ref_icon(parent, icon)
card = _safe_tbl(card)
icon = _safe_tbl(icon)
 
local page = _safe_str(icon.page, "")
local kind = _safe_str(card.kind, "")
local alt = _safe_str(icon.alt, "")
local title = _safe_str(card.title, "—")
local page = _safe_str(card.page, "")
local icon = _safe_tbl(card.icon)
 
local container = parent:tag("div")
:addClass("sv-tile")
:addClass("sv-hover-lift")
:addClass("sv-ref-card")
if kind ~= "" then
container:addClass("sv-ref-card--" .. _slugify(kind))
end
 
local ico = container:tag("div")
:addClass("sv-ref-ico")
:addClass("sv-tile")
render_ref_icon(ico, icon)
 
local text = container:tag("div"):addClass("sv-ref-text")
 
local title_div = text:tag("div"):addClass("sv-ref-title")
if page ~= "" then
if page ~= "" then
title_div:wikitext(string.format("[[%s|%s]]", page, mw.text.nowiki(title)))
parent:node(_render_file_image(page, alt, 52))
else
else
title_div:wikitext(mw.text.nowiki(title))
parent:node(_question_badge())
end
end
render_effect_stats(text, card.stats, kind)
end
end


do
local function render_effect_stats(parent, stats, kind)
local panel = panels:tag("div")
stats = _safe_tbl(stats)
:addClass("sv-tabpanel")
kind = _safe_str(kind, ""):lower()
:addClass("sv-mech-panel")
:attr("role", "tabpanel")
:attr("data-panel", "mechanics")


local mods_wrap = panel:tag("div")
if kind == "event" then
:addClass("sv-tab-section")
local sub = parent:tag("div"):addClass("sv-ref-sub")
:addClass("sv-mech-panel__mods")
if stats.text ~= nil then
_apply_value(sub, stats, level)
else
_apply_value(sub, { text = "" }, level)
end
return
end


local keys_wrap = panel:tag("div")
if next(stats) ~= nil then
:addClass("sv-tab-section")
local srow = parent:tag("div"):addClass("sv-ref-stats")
:addClass("sv-mech-panel__keywords")


if #mechanics_mods > 0 then
local d = srow:tag("span")
mods_wrap:tag("div"):addClass("sv-tab-section-title"):wikitext("Modifiers")
:addClass("sv-pill")
:addClass("sv-pill--value")
:addClass("sv-ref-stat")
_apply_value(d, stats.duration, level)


local gridwrap = mods_wrap:tag("div"):addClass("sv-kw-grid")
local c = srow:tag("span")
for _, item in ipairs(mechanics_mods) do
:addClass("sv-pill")
item = _safe_tbl(item)
:addClass("sv-pill--value")
:addClass("sv-ref-stat")
_apply_value(c, stats.chance, level)


local cell = gridwrap:tag("div")
local st = srow:tag("span")
:addClass("sv-tile")
:addClass("sv-pill")
:addClass("sv-kw-cell")
:addClass("sv-pill--value")
cell:tag("div")
:addClass("sv-ref-stat")
:addClass("sv-kw-label")
_apply_value(st, stats.stacks, level)
:wikitext(mw.text.nowiki(_safe_str(item.label, "")))
 
local v = cell:tag("div"):addClass("sv-kw-value")
_apply_value(v, item.value, level)
end
end
end
end


if #mechanics_keywords > 0 then
local function render_ref_card(parent, card)
keys_wrap:tag("div"):addClass("sv-tab-section-title"):wikitext("Keywords")
card = _safe_tbl(card)


local pills = keys_wrap:tag("div"):addClass("sv-tab-pills")
local kind = _safe_str(card.kind, "")
for _, kw in ipairs(mechanics_keywords) do
local title = _safe_str(card.title, "")
_build_keyword_pill(frame, pills, kw)
local page = _safe_str(card.page, "")
end
local icon = _safe_tbl(card.icon)
end


if #mechanics_mods == 0 and #mechanics_keywords == 0 then
local container = parent:tag("div")
panel:tag("div"):addClass("sv-tab-empty"):wikitext("")
:addClass("sv-tile")
:addClass("sv-hover-lift")
:addClass("sv-ref-card")
if kind ~= "" then
container:addClass("sv-ref-card--" .. _slugify(kind))
end
end
end


do
local ico = container:tag("div")
local panel = panels:tag("div")
:addClass("sv-ref-ico")
:addClass("sv-tabpanel")
:addClass("sv-tile")
:attr("role", "tabpanel")
render_ref_icon(ico, icon)
:attr("data-panel", "effects")
:attr("hidden", "hidden")


if #effects_cards > 0 then
local text = container:tag("div"):addClass("sv-ref-text")
local grid = panel:tag("div"):addClass("sv-ref-grid")
local title_div = text:tag("div"):addClass("sv-ref-title")
for _, card in ipairs(effects_cards) do
if page ~= "" then
render_ref_card(grid, card)
title_div:wikitext(string.format("[[%s|%s]]", page, mw.text.nowiki(title)))
end
else
else
panel:tag("div"):addClass("sv-tab-empty"):wikitext("—")
title_div:wikitext(mw.text.nowiki(title))
end
end
render_effect_stats(text, card.stats, kind)
end
end


return root
do
end
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:addClass("sv-mech-panel")
:attr("role", "tabpanel")
:attr("data-panel", "mechanics")


local function _map_to_groups(map_tbl, preferred_order)
local mods_wrap = panel:tag("div")
local m = _safe_tbl(map_tbl)
:addClass("sv-mech-panel__group")
:addClass("sv-mech-panel__group--mods")
:addClass("sv-mech-panel__mods")


local function normalize_items(v)
local keys_wrap = panel:tag("div")
if type(v) == "table" then
:addClass("sv-mech-panel__group")
return v
:addClass("sv-mech-panel__group--keywords")
end
:addClass("sv-mech-panel__keywords")
if v == nil then
return {}
end
return { v }
end


local used = {}
if #mechanics_mods > 0 then
local group_keys = {}
mods_wrap:tag("div"):addClass("sv-tab-section-title"):wikitext("Modifiers")


local function push_key(k)
local gridwrap = mods_wrap:tag("div"):addClass("sv-mech-mod-grid")
k = tostring(k)
for _, item in ipairs(mechanics_mods) do
if k ~= "" and not used[k] and m[k] ~= nil then
_build_modifier_pill(frame, gridwrap, item, level)
used[k] = true
end
table.insert(group_keys, k)
end
end
end


local explicit = m.__order or m.order
if #mechanics_keywords > 0 then
if type(explicit) == "table" then
keys_wrap:tag("div"):addClass("sv-tab-section-title"):wikitext("Keywords")
for _, k in ipairs(explicit) do
 
push_key(k)
local pills = keys_wrap:tag("div"):addClass("sv-tab-pills")
for _, kw in ipairs(mechanics_keywords) do
_build_keyword_pill(frame, pills, kw)
end
end
 
if #mechanics_mods == 0 and #mechanics_keywords == 0 then
panel:tag("div")
:addClass("sv-tile")
:addClass("sv-tab-empty")
:wikitext("—")
end
end
end
end


if type(preferred_order) == "table" then
do
for _, k in ipairs(preferred_order) do
local panel = panels:tag("div")
push_key(k)
:addClass("sv-tabpanel")
:addClass("sv-hidden")
:attr("role", "tabpanel")
:attr("data-panel", "effects")
:attr("hidden", "hidden")
 
if #effects_cards > 0 then
local grid = panel:tag("div"):addClass("sv-ref-grid")
for _, card in ipairs(effects_cards) do
render_ref_card(grid, card)
end
else
panel:tag("div")
:addClass("sv-tile")
:addClass("sv-tab-empty")
:wikitext("—")
end
end
end
end


local rest = {}
return root
for k, _ in pairs(m) do
end
k = tostring(k)
 
if k ~= "__order" and k ~= "order" and not used[k] then
local function _map_to_groups(map_tbl, preferred_order)
table.insert(rest, k)
local m = _safe_tbl(map_tbl)
 
local function normalize_items(v)
if type(v) == "table" then
return v
end
if v == nil then
return {}
end
end
end
return { v }
table.sort(rest)
for _, k in ipairs(rest) do
table.insert(group_keys, k)
end
end


local out_groups = {}
local used = {}
for _, title in ipairs(group_keys) do
local group_keys = {}
local items = normalize_items(m[title])
local out_items = {}


for _, it in ipairs(_safe_tbl(items)) do
local function push_key(k)
table.insert(out_items, it)
k = tostring(k)
if k ~= "" and not used[k] and m[k] ~= nil then
used[k] = true
table.insert(group_keys, k)
end
end
end


table.insert(out_groups, { title = title, items = out_items })
local explicit = m.__order or m.order
if type(explicit) == "table" then
for _, k in ipairs(explicit) do
push_key(k)
end
end
end


return out_groups
if type(preferred_order) == "table" then
end
for _, k in ipairs(preferred_order) do
push_key(k)
end
end


local function _build_req_users_native(root_id, requirements_map, users_map)
local rest = {}
local req_groups = _map_to_groups(requirements_map, { "Skills", "Weapon Types", "Stances" })
for k, _ in pairs(m) do
local usr_groups = _map_to_groups(users_map,        { "Classes", "Summons" })
k = tostring(k)
if k ~= "__order" and k ~= "order" and not used[k] then
table.insert(rest, k)
end
end
table.sort(rest)
for _, k in ipairs(rest) do
table.insert(group_keys, k)
end
 
local out_groups = {}
for _, title in ipairs(group_keys) do
local items = normalize_items(m[title])
local out_items = {}
 
for _, it in ipairs(_safe_tbl(items)) do
table.insert(out_items, it)
end
 
table.insert(out_groups, { title = title, items = out_items })
end
 
return out_groups
end
 
local function _build_req_users_native(root_id, requirements_map, users_map)
local req_groups = _map_to_groups(requirements_map, { "Skills", "Weapon Types", "Stances" })
local usr_groups = _map_to_groups(users_map,        { "Classes", "Monsters", "Summons" })


local req_box, req_n = _build_grouped_disclose(root_id, "req", "Requirements", req_groups)
local req_box, req_n = _build_grouped_disclose(root_id, "req", "Requirements", req_groups)
local usr_box, usr_n = _build_grouped_disclose(root_id, "usr", "Users", usr_groups)
local usr_box, usr_n = _build_grouped_disclose(root_id, "usr", "Users", usr_groups)


if req_n == 0 and usr_n == 0 then return nil end
if req_n == 0 and usr_n == 0 then return nil end
 
 
local row = mw.html.create("div"):addClass("sv-reqrow")
local row = mw.html.create("div"):addClass("sv-reqrow")
if req_box then row:node(req_box) end
if req_box then row:node(req_box) end
if usr_box then row:node(usr_box) end
if usr_box then row:node(usr_box) end
return row
return row
end
end
 
 
local function _build_req_users(root_id, requirements, users)
local function _build_req_users(root_id, requirements, users)
requirements = _safe_tbl(requirements)
requirements = _safe_tbl(requirements)
users = _safe_tbl(users)
users = _safe_tbl(users)
 
 
local req_box, req_n = _build_grouped_disclose(
local req_box, req_n = _build_grouped_disclose(
root_id, "req", "Requirements", _safe_tbl(requirements.groups)
root_id, "req", "Requirements", _safe_tbl(requirements.groups)
)
)
local usr_box, usr_n = _build_grouped_disclose(
local usr_box, usr_n = _build_grouped_disclose(
root_id, "usr", "Users", _safe_tbl(users.groups)
root_id, "usr", "Users", _safe_tbl(users.groups)
)
)
 
 
if req_n == 0 and usr_n == 0 then return nil end
if req_n == 0 and usr_n == 0 then return nil end
 
 
local row = mw.html.create("div"):addClass("sv-reqrow")
local row = mw.html.create("div"):addClass("sv-reqrow")
if req_box then row:node(req_box) end
if req_box then row:node(req_box) end
if usr_box then row:node(usr_box) end
if usr_box then row:node(usr_box) end
return row
return row
end
 
local function _append_meta_req_bubble(wrap, mod, node)
if not node then return false end
 
wrap:tag("div")
:addClass("sv-skill-meta-block__bubble")
:addClass("sv-skill-meta-block__bubble--" .. mod)
:node(node)
 
return true
end
 
local function _build_meta_req_block(meta_node, req_node)
if not meta_node and not req_node then return nil end
 
local wrap = mw.html.create("div")
:addClass("sv-skill-meta-block")
 
local count = 0
if _append_meta_req_bubble(wrap, "meta", meta_node) then count = count + 1 end
if _append_meta_req_bubble(wrap, "req", req_node) then count = count + 1 end
 
if count == 1 then
wrap:addClass("sv-skill-meta-block--single")
end
 
return wrap
end
end


Line 1,592: Line 1,667:


box.top:node(_build_identity(frame, root_id, identity, notes_wt))
box.top:node(_build_identity(frame, root_id, identity, notes_wt))
box.top:node(_build_meta_row(frame, payload.meta_row))


local meta_block = _build_meta_row(frame, payload.meta_row)
local reqrow = _build_req_users(root_id, payload.requirements, payload.users)
local reqrow = _build_req_users(root_id, payload.requirements, payload.users)
if reqrow then box.top:node(reqrow) end
local meta_req_block = _build_meta_req_block(meta_block, reqrow)
if meta_req_block then box.top:node(meta_req_block) end


local level_panel, actual_default = _build_level(root_id, default_level, max_level, 1, nil)
local level_panel, actual_default = _build_level(root_id, default_level, max_level, 1, nil)
box.bottom:node(level_panel)
box.bottom:node(level_panel)
box.bottom:node(_build_scaling_top(frame, payload.scaling_top, actual_default))
box.bottom:node(_build_scaling_top(frame, payload.scaling_top, actual_default))
Line 1,642: Line 1,717:


box.top:node(_build_identity(frame, root_id, identity, notes_wt))
box.top:node(_build_identity(frame, root_id, identity, notes_wt))
box.top:node(_build_meta_row_native(frame, payload.skill_meta))


local meta_block = _build_meta_row_native(frame, payload.skill_meta)
local reqrow = _build_req_users_native(root_id, payload.requirements, payload.users)
local reqrow = _build_req_users_native(root_id, payload.requirements, payload.users)
if reqrow then box.top:node(reqrow) end
local meta_req_block = _build_meta_req_block(meta_block, reqrow)
if meta_req_block then box.top:node(meta_req_block) end


local scaling_block, actual_default = _build_skill_scaling_native(
local scaling_block, actual_default = _build_skill_scaling_native(