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
No edit summary
Tags: Mobile edit Mobile web edit
 
(45 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:GameInfo/Skills
-- Module:GameInfo/Skills
-- Phase 4.1: Skills renderer (components live here; CSS/JS shared globally).
-- Phase 4.2 — Final native-first rendering (schema 1 + schema 2)
--
-- Notes:
-- - JSON Definition tokens must be plain strings that decode to Domain|Key.
-- - Interactivity is provided by sitewide popup systems in Common.js.
-- - Schema 2 is the primary layout target; schema 1 remains supported.
-- - Mechanics / keywords are JSON-driven; the renderer does not infer future
--  mechanic definition tokens from labels.


local p = {}
local p = {}


local GI = require("Module:GameInfo")
local GI = require("Module:GameInfo")
p.STYLE_SRC = "Module:GameInfo/styles.css"


local _file_exists_cache = {}
local _file_exists_cache = {}
local _STAT_ORDER = { "str", "vit", "dex", "agi", "int", "luk" }
local _STAT_GRID_ORDER = { "str", "agi", "vit", "int", "dex", "luk" }
local _STAT_LABELS = {
str = "STR",
vit = "VIT",
dex = "DEX",
agi = "AGI",
int = "INT",
luk = "LUK",
}
local _STAT_DEF_TOKENS = {
str = "Stat|Str",
vit = "Stat|Vit",
dex = "Stat|Dex",
agi = "Stat|Agi",
int = "Stat|Int",
luk = "Stat|Luk",
}
local _STAT_TOKEN_TO_KEY = {
["stat|str"] = "str",
["stat|vit"] = "vit",
["stat|dex"] = "dex",
["stat|agi"] = "agi",
["stat|int"] = "int",
["stat|luk"] = "luk",
}
local _STAT_ICON_FILES = {
str = "File:Strength.png",
vit = "File:Vitality.png",
dex = "File:Dexterity.png",
agi = "File:Agility.png",
int = "File:Intelligence.png",
luk = "File:Luck.png",
}
local _CORE_ORDER = {
{ field = "cost",      label = "Cost",      def_tok = "Skill|Cost" },
{ field = "cast_time", label = "Cast Time", def_tok = "Skill|CastTime" },
{ field = "cooldown",  label = "Cooldown",  def_tok = "Skill|Cooldown" },
{ field = "range",    label = "Range",    def_tok = "Skill|Range" },
{ field = "area",      label = "Area",      def_tok = "Skill|Area" },
{ field = "duration",  label = "Duration",  def_tok = "Skill|Duration" },
}


local function _trim(s)
local function _trim(s)
if s == nil then return "" end
if s == nil then return "" end
s = tostring(s)
s = tostring(s)
s = s:gsub("^%s+", ""):gsub("%s+$", "")
return (mw.text and mw.text.trim) and mw.text.trim(s)
return s
or (s:gsub("^%s+", ""):gsub("%s+$", ""))
end
end


Line 28: Line 85:
local n = tonumber(v)
local n = tonumber(v)
if not n then return fallback end
if not n then return fallback end
n = math.floor(n + 0.0)
return math.floor(n + 0.0)
return n
end
end


Line 39: Line 95:
end
end


-- -----------------------------------------------------------------------------
local function _humanize_key(s)
-- COMPAT / ARG + JSON HELPERS
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
end


local function _get_arg(frame, key)
local function _err(msg, preview)
-- Prefer direct args, but allow parent args (compat with routers/wrappers).
local box = mw.html.create("div")
local args = frame and frame.args or {}
:addClass("sv-card")
local v = _trim(args[key])
:addClass("sv-gi-error")
if v ~= "" then return v end


local parent = frame and frame.getParent and frame:getParent() or nil
box:wikitext("GameInfo/Skills error: " .. tostring(msg))
local pargs = parent and parent.args or {}
if preview and preview ~= "" then
v = _trim(pargs[key])
box:tag("pre"):wikitext(mw.text.nowiki(preview:sub(1, 480)))
if v ~= "" then return v end
end
 
return tostring(box)
return ""
end
end


local function _strip_wrappers(raw)
local function _decode_payload(frame)
local raw = GI.arg(frame, "data", "")
raw = _trim(raw)
raw = _trim(raw)
if raw == "" then return "" end
if raw == "" then return nil, "missing |data=", "" end
 
-- Common wrappers when people paste big blobs.
raw = raw:gsub("^<nowiki>%s*", ""):gsub("%s*</nowiki>%s*$", "")
raw = raw:gsub("^<pre>%s*", ""):gsub("%s*</pre>%s*$", "")
raw = raw:gsub("^<syntaxhighlight[^>]*>%s*", ""):gsub("%s*</syntaxhighlight>%s*$", "")
 
return _trim(raw)
end
 
local function _decode_json(raw)
raw = _strip_wrappers(raw)
if raw == "" then return nil, "missing data" end


local ok, decoded = pcall(mw.text.jsonDecode, raw)
local ok, decoded = pcall(mw.text.jsonDecode, raw)
if not ok then
if not ok then
return nil, "invalid json"
return nil, "invalid json", raw
end
end
if type(decoded) ~= "table" then
if type(decoded) ~= "table" then
return nil, "json is not object"
return nil, "json is not object", raw
end
end
return decoded, nil
 
local schema = decoded.schema
if schema ~= 1 and schema ~= 2 then
return nil, "unsupported schema=" .. tostring(schema), raw
end
 
return decoded, nil, ""
end
end


Line 86: Line 140:
page = _trim(page)
page = _trim(page)
if page == "" then return nil end
if page == "" then return nil end
if page:match("^[Ff]ile:") then
 
return "File:" .. page:sub(6)
if page:match("^[Ff]ile:") or page:match("^[Ii]mage:") then
page = page:gsub("^[Ii]mage:", "File:"):gsub("^[Ff]ile:", "File:")
return page
end
end
return "File:" .. page
return "File:" .. page
end
end
Line 96: Line 153:
local cached = _file_exists_cache[file_title]
local cached = _file_exists_cache[file_title]
if cached ~= nil then return cached end
if cached ~= nil then return cached end
local exists = false
local t = mw.title.new(file_title)
local t = mw.title.new(file_title)
local exists = (t and t.exists) == true
if t then
if t.exists then
exists = true
else
local ok, fileobj = pcall(function() return t.file end)
if ok and fileobj then exists = true end
end
end
 
_file_exists_cache[file_title] = exists
_file_exists_cache[file_title] = exists
return exists
return exists
Line 116: Line 183:


local size = tostring(size_px or 48) .. "px"
local size = tostring(size_px or 48) .. "px"
local fname = title
return mw.html.create("span")
return mw.html.create("span")
:addClass("sv-img")
:addClass("sv-img")
:wikitext(string.format("[[%s|%s|link=|alt=%s]]", fname, size, mw.text.nowiki(_safe_str(alt, ""))))
:wikitext(string.format(
"[[%s|%s|link=|alt=%s]]",
title,
size,
mw.text.nowiki(_safe_str(alt, ""))
))
end
end


-- Phase 4.1 rule: values are display-only:
-- - { text = "..." } = constant
-- - { series = [...] } = per-level; Lua emits data-series for JS to swap
local function _apply_value(node, val, level)
local function _apply_value(node, val, level)
if val == nil then
if val == nil then
Line 132: Line 199:
end
end


-- Support accidental raw strings/numbers defensively.
if type(val) ~= "table" then
if type(val) ~= "table" then
node:wikitext(mw.text.nowiki(tostring(val)))
node:wikitext(mw.text.nowiki(tostring(val)))
Line 140: Line 206:
local series = val.series
local series = val.series
if type(series) == "table" and #series > 0 then
if type(series) == "table" and #series > 0 then
-- Attach series for Common.js Level Selector.
node:attr("data-series", mw.text.jsonEncode(series))
node:attr("data-series", mw.text.jsonEncode(series))
local v = series[level] or series[#series]
local v = series[level] or series[#series]
Line 156: Line 221:
end
end


local function _meta_lines(lines)
local function _has_pipe(s)
s = _trim(s)
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
 
local function _render_def_token(frame, s, noicon, extra_args)
s = _trim(s)
if s == "" then return nil end
 
local domain, key = _split_def_token(s)
if not domain or not key then
return mw.text.nowiki(s)
end
 
local args = { domain, key }
if noicon then
args.noicon = "1"
end
if type(extra_args) == "table" then
for k, v in pairs(extra_args) do
if v ~= nil and tostring(v) ~= "" then
args[k] = tostring(v)
end
end
end
 
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
 
local function _meta_lines_def(frame, lines)
lines = _safe_tbl(lines)
lines = _safe_tbl(lines)
local wrap = mw.html.create("span"):addClass("sv-meta-lines")
local wrap = mw.html.create("span"):addClass("sv-meta-lines")
Line 163: Line 280:
local s = _trim(ln)
local s = _trim(ln)
if s ~= "" then
if s ~= "" then
wrap:tag("span"):wikitext(mw.text.nowiki(s))
local wt = _render_def_token(frame, s, true) or mw.text.nowiki(s)
wrap:tag("span"):wikitext(wt)
any = true
any = true
end
end
Line 173: Line 291:
end
end


local function _build_notes_tip(notes_wt)
local function _build_hitbox(parent, class_name, frame, token)
token = _safe_str(token, "")
if not _has_pipe(token) then return false end
 
local wt = _render_def_token(frame, token, true, { pill = "1", fill = "1" })
if not wt then return false end
 
parent:tag("span")
:addClass(class_name)
:attr("aria-hidden", "true")
:wikitext(wt)
 
return true
end
 
local function _is_blankish(v)
if v == nil then return true end
local s = _trim(v)
return s == "" or s == "—" or s == "-" or s == "--"
end
 
local function _value_has_content(val)
if val == nil then return false end
 
if type(val) ~= "table" then
return not _is_blankish(val)
end
 
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
return false
end
 
if val.text ~= nil then
return not _is_blankish(val.text)
end
 
return false
end
 
local function _stat_key_from_token(token)
token = _trim(token):lower()
if token == "" then return nil end
return _STAT_TOKEN_TO_KEY[token]
end
 
local function _normalize_stat_scaling_slots(stat_scaling)
local slots = {}
for _, key in ipairs(_STAT_ORDER) do
slots[key] = nil
end
 
for _, it in ipairs(_safe_tbl(stat_scaling)) do
it = _safe_tbl(it)
 
local legacy_tok = _safe_str(it.stat_wt or it.stat or "", "")
if legacy_tok ~= "" then
local key = _stat_key_from_token(legacy_tok)
if key then
slots[key] = it.value
end
end
 
for k, v in pairs(it) do
local lk = _trim(k):lower()
if _STAT_LABELS[lk] ~= nil then
slots[lk] = v
end
end
end
 
return slots
end
 
local function _split_lines(s)
if s == nil then return {} end
s = tostring(s)
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
 
local function _render_notes_body(parent, notes_wt)
notes_wt = _trim(notes_wt)
if notes_wt == "" then
parent:wikitext("—")
return
end
 
notes_wt = notes_wt:gsub("(^|\n)%s+([*#])", "%1%2")
 
local lines = _split_lines(notes_wt)
local i = 1
local wrote_any = false
 
local function is_list_line(ln)
return type(ln) == "string" and ln:match("^[*#]") ~= nil
end
 
while i <= #lines do
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 items = {}
while i <= #lines do
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*", "")
txt = _trim(txt)
if txt ~= "" then table.insert(items, txt) end
i = i + 1
end
 
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
 
if #block > 0 then
local joined = table.concat(block, "<br />\n")
parent:tag("div"):addClass("sv-note-par"):wikitext(joined)
wrote_any = true
end
end
end
 
if not wrote_any then
parent:wikitext(mw.text.nowiki(notes_wt))
end
end
 
local function _build_notes_tip(frame, root_id, notes_wt)
notes_wt = _trim(notes_wt)
notes_wt = _trim(notes_wt)
if notes_wt == "" then return nil end
if notes_wt == "" then return nil end


local d = mw.html.create("details"):addClass("sv-tip")
local tip_id = root_id .. "-notes"
local summary = d:tag("summary")
local wrap = mw.html.create("div"):addClass("sv-tip")
 
local btn = wrap:tag("span")
:addClass("sv-tip-btn")
:addClass("sv-tip-btn")
:addClass("sv-tip-btn--icon")
:addClass("sv-hover-lift")
:attr("role", "button")
:attr("role", "button")
:attr("tabindex", "0")
: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-label", "Notes")
:attr("aria-controls", tip_id)
:attr("aria-expanded", "false")
:attr("aria-expanded", "false")


-- Info icon (matches locked baseline)
btn:tag("span")
summary:tag("span")
:addClass("sv-ico")
:addClass("sv-ico")
:addClass("sv-ico--info")
:addClass("sv-ico--info")
Line 192: Line 483:
:wikitext("i")
:wikitext("i")


local pop = d:tag("div")
local pop = wrap:tag("div")
:addClass("sv-tip-pop")
:addClass("sv-tip-pop")
:attr("role", "dialog")
:addClass("sv-hidden")
:attr("hidden", "hidden")
:attr("id", tip_id)
:attr("aria-label", "Notes")
:attr("aria-label", "Notes")


local head = pop:tag("div"):addClass("sv-tip-pop-head")
local head = pop:tag("div"):addClass("sv-tip-pop-head")
head:tag("div"):addClass("sv-tip-pop-title"):wikitext("Notes")
head:tag("div"):addClass("sv-tip-pop-title"):wikitext("Notes")
head:tag("div"):addClass("sv-tip-pop-hint"):wikitext("Click to close")


pop:tag("div"):addClass("sv-tip-pop-body"):wikitext(notes_wt)
local body = pop:tag("div"):addClass("sv-tip-pop-body")
_render_notes_body(body, notes_wt)


return d
return wrap
end
end


local function _build_identity(identity, notes_wt)
local function _normalize_overcap_sources(sources, total_bonus)
identity = _safe_tbl(identity)
sources = _safe_tbl(sources)
local root = mw.html.create("div"):addClass("sv-skill-head")
 
local out = {}
local missing = 0
local explicit_sum = 0
 
for _, s in ipairs(sources) do
local page = ""
local lv = nil
 
if type(s) == "string" then
page = _trim(s)
elseif type(s) == "table" then
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
if lv and lv > 0 then
explicit_sum = explicit_sum + lv
else
lv = nil
missing = missing + 1
end
table.insert(out, { page = page, levels = lv })
end
end


local icon_div = root:tag("div"):addClass("sv-skill-icon")
local remaining = (_to_int(total_bonus, 0) or 0) - explicit_sum
local sprite = _safe_tbl(identity.sprite)
if remaining < 0 then remaining = 0 end
local sprite_page = _safe_str(sprite.page, "")
local sprite_alt = _safe_str(sprite.alt, _safe_str(identity.display_name, ""))


if sprite_page ~= "" then
if missing == 1 and remaining > 0 then
icon_div:node(_render_file_image(sprite_page, sprite_alt, 64))
for _, it in ipairs(out) do
else
if it.levels == nil then
icon_div:node(_question_badge())
it.levels = remaining
break
end
end
end
end


local headtext = root:tag("div"):addClass("sv-skill-headtext")
return out
end
 
local function _build_overcap_tip(root_id, default_cap, max_cap, 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 total = max_cap - default_cap
local tip_id = root_id .. "-overcap"
 
local norm = _normalize_overcap_sources(sources, total)
local show_unknown = (#norm == 0)
 
local wrap = mw.html.create("span")
:addClass("sv-tip")
:addClass("sv-overcap-tip")
 
local btn = wrap:tag("span")
: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")
:addClass("sv-tip-pop")
:addClass("sv-hidden")
:attr("hidden", "hidden")
:attr("id", tip_id)
:attr("aria-label", "Overcap")
 
local head = pop:tag("div"):addClass("sv-tip-pop-head")
head:tag("div"):addClass("sv-tip-pop-title"):wikitext("Overcap sources")
 
local body = pop:tag("div"):addClass("sv-tip-pop-body")
body:tag("div"):addClass("sv-overcap-summary"):wikitext(mw.text.nowiki(
"Raises max from " .. tostring(default_cap) .. " to " .. tostring(max_cap) .. " (+" .. tostring(total) .. ")."
))
 
local ul = body:tag("ul"):addClass("sv-disclose-list")


local title_row = headtext:tag("div"):addClass("sv-skill-title-row")
if show_unknown then
title_row:tag("div")
ul:tag("li"):wikitext("Source unknown")
:addClass("sv-skill-title")
else
:wikitext(mw.text.nowiki(_safe_str(identity.display_name, "Unknown Skill")))
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 tip = _build_notes_tip(notes_wt)
if it.levels ~= nil and it.levels > 0 then
if tip then
li:wikitext(mw.text.nowiki(" (+" .. tostring(it.levels) .. ")"))
title_row:node(tip)
else
li:wikitext(mw.text.nowiki(" (+?)"))
end
end
end
end


headtext:tag("div")
return wrap
:addClass("sv-skill-desc")
end
:wikitext(mw.text.nowiki(_safe_str(identity.description, "")))


return root
local function _normalize_disclose_item(it)
end
if type(it) == "string" then
local s = _trim(it)
if s == "" then return nil end
return { text = s, page = s }
end


local function _build_meta_row(meta_row)
if type(it) ~= "table" then return nil end
meta_row = _safe_tbl(meta_row)


local meta = mw.html.create("div"):addClass("sv-skill-meta")
if it.text ~= nil or it.page ~= nil or it.suffix ~= nil or it.name ~= nil or it.label ~= nil then
for i = 1, 4 do
local text = _safe_str(it.text or it.label or it.name or "", "")
local cell = _safe_tbl(meta_row[i])
if text == "" then return nil end
local card = meta:tag("div"):addClass("sv-meta-card")
local page = _safe_str(it.page or "", "")
if page == "" then page = text end
local suffix = _safe_str(it.suffix or "", "")
return { text = text, page = (page ~= "" and page or nil), suffix = (suffix ~= "" and suffix or nil) }
end


local icon = _safe_tbl(cell.icon)
if #it >= 1 then
local icon_page = _safe_str(icon.page, "")
local name = _safe_str(it[1], "")
local icon_alt = _safe_str(icon.alt, "")
if name == "" then return nil end
local suffix = _safe_str(it[2], "")
return {
text = name,
page = name,
suffix = (suffix ~= "" and suffix or nil)
}
end


local icon_div = card:tag("div"):addClass("sv-meta-icon")
local lines = _safe_tbl(it.label_lines)
if icon_page ~= "" then
local text = ""
icon_div:node(_render_file_image(icon_page, icon_alt, 24))
for _, ln in ipairs(lines) do
else
local s = _trim(ln)
icon_div:node(_question_badge())
if s ~= "" then
if text ~= "" then text = text .. " " end
text = text .. s
end
end
local wrap = card:tag("div"):addClass("sv-meta-textwrap")
wrap:tag("div"):addClass("sv-meta-text"):node(_meta_lines(cell.label_lines))
end
end
if text == "" then return nil end


return meta
local link = _safe_tbl(it.link)
local page = _safe_str(link.page or it.page or "", "")
return { text = text, page = (page ~= "" and page or nil) }
end
end


Line 269: Line 661:
local n = 0
local n = 0
for _, g in ipairs(_safe_tbl(groups)) do
for _, g in ipairs(_safe_tbl(groups)) do
for _, _ in ipairs(_safe_tbl(g.items)) do n = n + 1 end
for _, it in ipairs(_safe_tbl(g.items)) do
if _normalize_disclose_item(it) then n = n + 1 end
end
end
end
return n
return n
end
end


local function _build_grouped_disclose(label, groups)
local function _build_grouped_disclose(root_id, key, label, groups)
local total = _count_group_items(groups)
local total = _count_group_items(groups)
if total == 0 then return nil, 0 end
if total == 0 then return nil, 0 end


local d = mw.html.create("details"):addClass("sv-disclose")
local pop_id = root_id .. "-" .. key
local sum = d:tag("summary")
local wrap = mw.html.create("div"):addClass("sv-disclose")
 
local btn = wrap:tag("span")
:addClass("sv-disclose-btn")
:addClass("sv-disclose-btn--compact")
:addClass("sv-hover-lift")
:attr("role", "button")
:attr("role", "button")
:attr("tabindex", "0")
:attr("tabindex", "0")
:attr("data-sv-toggle", "1")
:attr("data-sv-pop", "click")
:attr("data-sv-pop-size", "sm")
:attr("aria-label", label)
:attr("aria-label", label)
:attr("aria-controls", pop_id)
:attr("aria-expanded", "false")
:attr("aria-expanded", "false")


sum:tag("span"):wikitext(mw.text.nowiki(label))
btn:tag("span"):addClass("sv-disclose-label"):wikitext(mw.text.nowiki(label))
sum:tag("span"):addClass("sv-disclose-count"):wikitext("(" .. tostring(total) .. ")")
btn:tag("span"):addClass("sv-disclose-count"):wikitext(" (" .. tostring(total) .. ")")


local pop = d:tag("div")
local pop = wrap:tag("div")
:addClass("sv-disclose-pop")
:addClass("sv-disclose-pop")
:attr("role", "dialog")
:addClass("sv-hidden")
:attr("hidden", "hidden")
:attr("id", pop_id)
:attr("aria-label", label)
:attr("aria-label", label)


local head = pop:tag("div"):addClass("sv-disclose-pop-head")
local head = pop:tag("div"):addClass("sv-disclose-pop-head")
head:tag("div"):addClass("sv-disclose-pop-title"):wikitext(mw.text.nowiki(label))
head:tag("div"):addClass("sv-disclose-pop-title"):wikitext(mw.text.nowiki(label))
head:tag("div"):addClass("sv-disclose-pop-hint"):wikitext("Click to close")


local ul = pop:tag("ul"):addClass("sv-disclose-list")
local body = pop:tag("div"):addClass("sv-disclose-pop-body")
local secs = body:tag("div"):addClass("sv-disclose-sections")
 
local function render_item(parent, norm)
local item = parent:tag("span"):addClass("sv-disclose-item")
 
if norm.page then
item:wikitext(string.format(
"[[%s|%s]]",
norm.page,
mw.text.nowiki(norm.text)
))
else
item:wikitext(mw.text.nowiki(norm.text))
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


for _, g in ipairs(_safe_tbl(groups)) do
for _, g in ipairs(_safe_tbl(groups)) do
local title = _safe_str(g.title, "")
local title = _safe_str(g.title, "")
local items = _safe_tbl(g.items)
local items = _safe_tbl(g.items)


if title ~= "" and #items > 0 then
if #items > 0 then
ul:tag("li"):addClass("sv-disclose-group-title"):wikitext(mw.text.nowiki(title))
local sec = secs:tag("div"):addClass("sv-disclose-sec")
end
sec:tag("div"):addClass("sv-disclose-sec-title"):wikitext(mw.text.nowiki(title))


for _, it in ipairs(items) do
local line = sec:tag("div"):addClass("sv-disclose-sec-items")
it = _safe_tbl(it)
local li = ul:tag("li")


local label_node = _meta_lines(it.label_lines)
local norms = {}
for _, it in ipairs(items) do
local norm = _normalize_disclose_item(it)
if norm then table.insert(norms, norm) end
end


local link = _safe_tbl(it.link)
if #norms == 0 then
local page = _safe_str(link.page, "")
line:tag("span"):addClass("sv-disclose-item"):wikitext("")
if page ~= "" then
li:wikitext(string.format("[[%s|%s]]", page, tostring(label_node)))
else
else
li:node(label_node)
for i, norm in ipairs(norms) do
render_item(line, norm)
if i < #norms then line:wikitext(", ") end
end
end
end
end
end
end
end


return d, total
return wrap, total
end
 
local function _build_identity(frame, root_id, identity, notes_wt)
identity = _safe_tbl(identity)
local root = mw.html.create("div"):addClass("sv-skill-head")
 
local icon_div = root:tag("div")
:addClass("sv-skill-icon")
:addClass("sv-tile")
 
local sprite = _safe_tbl(identity.sprite)
local sprite_page = _safe_str(sprite.page, "")
local sprite_alt = _safe_str(sprite.alt, _safe_str(identity.display_name, ""))
 
if sprite_page ~= "" then
icon_div:node(_render_file_image(sprite_page, sprite_alt, 64))
else
icon_div:node(_question_badge())
end
 
local headtext = root:tag("div"):addClass("sv-skill-headtext")
 
local title_row = headtext:tag("div"):addClass("sv-skill-title-row")
title_row:tag("div")
:addClass("sv-skill-title")
:wikitext(mw.text.nowiki(_safe_str(identity.display_name, "Unknown Skill")))
 
local tip = _build_notes_tip(frame, root_id, notes_wt)
if tip then title_row:node(tip) end
 
headtext:tag("div")
:addClass("sv-skill-desc")
:wikitext(mw.text.nowiki(_safe_str(identity.description, "")))
 
return root
end
end


local function _build_req_users(requirements, users)
local function _build_meta_card(frame, parent, spec)
requirements = _safe_tbl(requirements)
spec = _safe_tbl(spec)
users = _safe_tbl(users)
 
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")
 
local icon_page = _safe_str(spec.icon_page, "")
local icon_alt = _safe_str(spec.icon_alt, "")
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(spec.lines)
if #lines > 0 then
text:node(_meta_lines_def(frame, lines))
else
text:wikitext("—")
end
 
_build_hitbox(card, "sv-meta-hit", frame, spec.hit_tok)
end
 
local function _build_meta_row(frame, meta_row)
meta_row = _safe_tbl(meta_row)
 
local meta = mw.html.create("div"):addClass("sv-skill-meta")
for i = 1, 4 do
local cell = _safe_tbl(meta_row[i])
local icon = _safe_tbl(cell.icon)
local lines = _safe_tbl(cell.label_lines)
local hit_tok = ""


local req_details, req_n = _build_grouped_disclose("Requirements", _safe_tbl(requirements.groups))
for _, ln in ipairs(lines) do
local usr_details, usr_n = _build_grouped_disclose("Users", _safe_tbl(users.groups))
local s = _safe_str(ln, "")
if _has_pipe(s) then
hit_tok = s
break
end
end


if req_n == 0 and usr_n == 0 then
_build_meta_card(frame, meta, {
return nil
icon_page = _safe_str(icon.page, ""),
icon_alt = _safe_str(icon.alt, ""),
lines = lines,
hit_tok = hit_tok,
})
end
end


local row = mw.html.create("div"):addClass("sv-reqrow")
return meta
if req_details then row:node(req_details) end
end
if usr_details then row:node(usr_details) end
 
local function _build_meta_row_native(frame, skill_meta)
skill_meta = _safe_tbl(skill_meta)
 
local meta = mw.html.create("div"):addClass("sv-skill-meta")
for i = 1, 4 do
local cell = _safe_tbl(skill_meta[i])
local lines = _safe_tbl(cell.label_lines)
local hit_tok = _safe_str(cell.label_wt, "")
 
if #lines == 0 and hit_tok ~= "" then
lines = { hit_tok }
end
 
_build_meta_card(frame, meta, {
icon_page = _safe_str(cell.icon, ""),
icon_alt = "",
lines = lines,
hit_tok = hit_tok,
})
end


return row
return meta
end
end


-- Level selector: this is the "boundary" for Common.js.
local function _normalize_level(level_obj)
-- Only fields below this block should carry data-series and update on slider change.
local function _build_level(level_obj)
level_obj = _safe_tbl(level_obj)
level_obj = _safe_tbl(level_obj)


local default = _to_int(level_obj.default, 1)
local max = _to_int(level_obj.max, nil)
local max = _to_int(level_obj.max, _to_int(level_obj.base_max, 1))
local base_max = _to_int(level_obj.base_max, nil)
 
if not max then max = base_max or 1 end
if not base_max then base_max = max end


if max < 1 then max = 1 end
if max < 1 then max = 1 end
if default < 1 then default = 1 end
if base_max < 1 then base_max = 1 end
if base_max > max then base_max = max end
 
local default = base_max
return default, base_max, max
end
 
local function _normalize_level_native(level_obj)
level_obj = _safe_tbl(level_obj)
 
local min_level = _to_int(level_obj.base, 1)
if not min_level or min_level < 1 then min_level = 1 end
 
local default = _to_int(level_obj.default, min_level)
local oc = _safe_tbl(level_obj.overcap)
local max_level = _to_int(oc.max, default)
 
if not max_level or max_level < min_level then max_level = min_level end
if not default or default < min_level then default = min_level end
if default > max_level then default = max_level end
 
return min_level, default, max_level
end
 
local function _build_level(root_id, default_level, max_level, min_level, overcap_obj)
local min = _to_int(min_level, 1)
local default = _to_int(default_level, 1)
local max = _to_int(max_level, 1)
 
if min < 1 then min = 1 end
if max < min then max = min end
if default < min then default = min end
if default > max then default = max end
if default > max then default = max end


local root = mw.html.create("div"):addClass("sv-skill-level")
local root = mw.html.create("div")
:addClass("sv-card")
:addClass("sv-skill-level")
:attr("data-sv-level-boundary", "1")
:attr("data-sv-level-hide-ticks", "1")
 
local ui = root:tag("div"):addClass("sv-level-ui")


local ui = root:tag("div"):addClass("sv-level-ui"):addClass("sv-level-ui-centered")
local label = ui:tag("div")
ui:tag("div")
:addClass("sv-level-label")
:addClass("sv-level-label")
:wikitext("Level ")
:attr("data-sv-helper-text", "Slide to set level")
:tag("span"):addClass("sv-level-num"):wikitext(tostring(default))
label:wikitext("Level ")
ui:tag("div"):addClass("sv-level-label"):wikitext(" / " .. tostring(max))
label:tag("span"):addClass("sv-level-num"):wikitext(tostring(default))
label:wikitext(" / ")
label:tag("span"):addClass("sv-level-max"):wikitext(tostring(max))
 
if type(overcap_obj) == "table" and max > default then
local oc = _safe_tbl(overcap_obj)
local tip = _build_overcap_tip(root_id, default, max, oc.sources)
if tip then ui:node(tip) end
elseif max > default then
local tip = _build_overcap_tip(root_id, default, max, nil)
if tip then ui:node(tip) end
end
 
local slider_wrap = root:tag("div"):addClass("sv-level-slider")


local slider = root:tag("div"):addClass("sv-level-slider")
local slider = slider_wrap:tag("span")
slider:tag("input")
:attr("type", "range")
:addClass("sv-level-range")
:addClass("sv-level-range")
:attr("min", "1")
:addClass("sv-level-range--custom")
:attr("max", tostring(max))
:attr("data-sv-slider", "1")
:attr("value", tostring(default))
:attr("role", "slider")
:attr("step", "1")
:attr("tabindex", "0")
:attr("aria-label", "Skill level select")
:attr("aria-label", "Skill level select")
:attr("style", "--sv-steps:" .. tostring(max) .. ";")
:attr("aria-valuemin", tostring(min))
:attr("aria-valuemax", tostring(max))
:attr("aria-valuenow", tostring(default))
:attr("data-min", tostring(min))
:attr("data-max", tostring(max))
:attr("data-value", tostring(default))
 
local track = slider:tag("span"):addClass("sv-level-track"):attr("aria-hidden", "true")
track:tag("span"):addClass("sv-level-fill"):attr("aria-hidden", "true")
slider:tag("span"):addClass("sv-level-thumb"):attr("aria-hidden", "true")


slider:tag("div"):addClass("sv-level-ticklabels"):attr("aria-hidden", "true")
slider_wrap:tag("div"):addClass("sv-level-ticklabels"):attr("aria-hidden", "true")


return root, default, max
return root, default, max, min
end
end


local function _build_scaling_top(scaling, level)
local function _build_scaling_top(frame, scaling, level)
scaling = _safe_tbl(scaling)
scaling = _safe_tbl(scaling)


local root = mw.html.create("div"):addClass("sv-skill-scaling")
local root = mw.html.create("div")
:addClass("sv-card")
:addClass("sv-skill-scaling")
 
local row = root:tag("div"):addClass("sv-scaling-row")
local row = root:tag("div"):addClass("sv-scaling-row")
local grid = row:tag("div"):addClass("sv-scaling-grid")
local grid = row:tag("div"):addClass("sv-scaling-grid")


do
do
local col = grid:tag("div"):addClass("sv-scaling-col"):addClass("sv-scaling-col--damage")
local col = grid:tag("div"):addClass("sv-scaling-col")
local v = col:tag("div"):addClass("sv-scaling-value")
local v = col:tag("div"):addClass("sv-scaling-value")
_apply_value(v, scaling.damage, level)
_apply_value(v, scaling.damage, level)
Line 396: Line 987:


do
do
local col = grid:tag("div"):addClass("sv-scaling-col"):addClass("sv-scaling-col--modifier")
local col = grid:tag("div"):addClass("sv-scaling-col sv-scaling-col--scaling")
local v = col:tag("div"):addClass("sv-scaling-value")
local list = col:tag("div"):addClass("sv-scaling-list")
_apply_value(v, scaling.modifier, level)
 
col:tag("div"):addClass("sv-scaling-label"):wikitext("Modifier")
for _, it in ipairs(_safe_tbl(scaling.stat_scaling)) do
end
it = _safe_tbl(it)
local tok = _safe_str(it.stat_wt or it.stat or "", "")
local val = _safe_str(it.value or "", "")
if tok ~= "" and val ~= "" then
local item = list:tag("div")
:addClass("sv-tile")
:addClass("sv-scaling-item")
:addClass("sv-scaling-item--stat")
local wt = _render_def_token(frame, tok, false) or mw.text.nowiki(tok)
item:tag("span"):addClass("sv-scale-stat"):wikitext(wt)
item:tag("span"):addClass("sv-scale-val"):wikitext(mw.text.nowiki(val))
end
end


do
local col = grid:tag("div"):addClass("sv-scaling-col"):addClass("sv-scaling-col--scaling")
local list = col:tag("div"):addClass("sv-scaling-list")
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 417: Line 1,019:
core_stats = _safe_tbl(core_stats)
core_stats = _safe_tbl(core_stats)


local root = mw.html.create("div"):addClass("sv-skill-core")
local root = mw.html.create("div")
:addClass("sv-card")
:addClass("sv-skill-core")
 
local row = root:tag("div"):addClass("sv-core-row")
local row = root:tag("div"):addClass("sv-core-row")
local grid = row:tag("div"):addClass("sv-core-grid")
local grid = row:tag("div"):addClass("sv-core-grid")
Line 424: Line 1,029:
cell = _safe_tbl(cell)
cell = _safe_tbl(cell)


local c = grid:tag("div"):addClass("sv-core-cell")
local c = grid:tag("div")
:addClass("sv-core-cell")
:addClass("sv-tile")


local top = c:tag("div"):addClass("sv-core-top")
local top = c:tag("div"):addClass("sv-core-top")
Line 437: Line 1,044:
local key = _safe_str(cell.key, "")
local key = _safe_str(cell.key, "")
local label = c:tag("div"):addClass("sv-core-label"):wikitext(mw.text.nowiki(key))
local label = c:tag("div"):addClass("sv-core-label"):wikitext(mw.text.nowiki(key))
if key:len() >= 9 then
if key:len() >= 9 then label:addClass("sv-core-label--tight") end
label:addClass("sv-core-label--tight")
end
 
return root
end
 
local function _build_skill_scaling_stat_pill(frame, parent, stat_key, raw_val, level)
local label = _STAT_LABELS[stat_key] or stat_key:upper()
local def_tok = _STAT_DEF_TOKENS[stat_key] or label
local icon_file = _STAT_ICON_FILES[stat_key]
local active = _value_has_content(raw_val)
 
local pill = parent:tag("div")
:addClass("sv-pill")
:addClass("sv-pill--compact")
:addClass("sv_skill_scaling__pill")
:addClass("sv_skill_scaling__pill--stat")
:addClass("sv_skill_scaling__stat-pill")
:addClass("sv_skill_scaling__stat-pill--" .. stat_key)
:attr("data-stat-key", stat_key)
:attr("aria-label", label)
 
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 visual = main:tag("span"):addClass("sv_skill_scaling__stat-visual")
 
if active then
local icon = visual:tag("span"):addClass("sv_skill_scaling__stat-icon")
if icon_file and icon_file ~= "" then
icon:node(_render_file_image(icon_file, label, 14))
else
icon:node(_question_badge())
end
end
end
end


return root
local val = visual:tag("span"):addClass("sv_skill_scaling__stat-value")
_apply_value(val, raw_val, level)
 
if active then
_build_hitbox(pill, "sv_skill_scaling__stat-hit", frame, def_tok)
end
end
 
local function _build_skill_scaling_core_pill(frame, parent, spec, fld, level)
fld = _safe_tbl(fld)
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")
:addClass("sv-tile")
:addClass("sv_skill_scaling__pill")
:addClass("sv_skill_scaling__pill--core")
:addClass("sv_skill_scaling__core-pill")
:addClass("sv_skill_scaling__core-pill--" .. spec.field)
:addClass("sv-hover-lift")
:attr("data-core-key", spec.field)
:attr("aria-label", resolved_label)
 
pill:addClass(active and "is-active" or "is-inactive")
 
local top = pill:tag("div"):addClass("sv_skill_scaling__core-main")
local value = top:tag("span"):addClass("sv_skill_scaling__core-value")
_apply_value(value, fld.value, level)
 
local unit = _safe_str(fld.unit, "")
if unit ~= "" then
top:tag("span")
:addClass("sv_skill_scaling__core-unit")
:wikitext(mw.text.nowiki(unit))
end
 
pill:tag("div")
:addClass("sv_skill_scaling__core-label")
:wikitext(mw.text.nowiki(resolved_label))
 
_build_hitbox(pill, "sv_skill_scaling__core-hit", frame, resolved_tok)
end
 
local function _build_skill_scaling_native(frame, root_id, skill_scaling, default_level, max_level, min_level, overcap_obj)
skill_scaling = _safe_tbl(skill_scaling)
 
local root = mw.html.create("div")
:addClass("sv-card")
:addClass("sv_skill_scaling")
 
local level_panel, actual_default = _build_level(
root_id,
default_level,
max_level,
min_level,
overcap_obj
)
level_panel:addClass("sv_skill_scaling__level")
root:node(level_panel)
 
local body = root:tag("div"):addClass("sv_skill_scaling__body")
 
local damage = _safe_tbl(skill_scaling.damage)
local damage_label = _safe_str(damage.label, "Damage")
local damage_value = _safe_tbl(damage.value)
 
do
local primary_group = body:tag("div")
:addClass("sv_skill_scaling__group")
:addClass("sv_skill_scaling__group--primary-stats")
 
local cluster = primary_group:tag("div"):addClass("sv_skill_scaling__cluster")
 
do
local primary = cluster:tag("div")
:addClass("sv_skill_scaling__column")
:addClass("sv_skill_scaling__column--primary")
:addClass("sv_skill_scaling__primary")
 
local v = primary:tag("div"):addClass("sv_skill_scaling__primary-value")
_apply_value(v, damage_value, actual_default)
 
primary:tag("div")
:addClass("sv_skill_scaling__primary-label")
:wikitext(mw.text.nowiki(damage_label))
end
 
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
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--core")
:addClass("sv_skill_scaling__core")
 
local core_grid = core_col:tag("div"):addClass("sv_skill_scaling__core-grid")
 
for _, spec in ipairs(_CORE_ORDER) do
_build_skill_scaling_core_pill(frame, core_grid, spec, skill_scaling[spec.field], actual_default)
end
end
 
return root, actual_default
end
 
local function _normalize_modifier_spec(item)
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 has_def = _has_pipe(spec.label_tok)
local has_value = _value_has_content(spec.value)
 
local pill = parent:tag("div")
:addClass("sv-tile")
:addClass("sv-mech-mod-pill")
:attr("aria-label", spec.label_text)
 
if has_value then
pill:addClass("is-active")
else
pill:addClass("is-inactive")
end
 
if has_def or has_value then
pill:addClass("sv-hover-lift")
end
 
pill:tag("div")
:addClass("sv-mech-mod-pill__label")
:wikitext(mw.text.nowiki(spec.label_text))
 
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
return {
label_tok = (_has_pipe(tok) and tok or ""),
label_text = label,
}
end
 
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
return {
label_tok = tok,
label_text = label,
}
end
end


local function _tab_ids(root_id, idx)
local function _build_keyword_pill(frame, parent, item)
return root_id .. "-tab-" .. tostring(idx), root_id .. "-panel-" .. tostring(idx)
local spec = _normalize_keyword_spec(item)
local display_text = _safe_str(spec.label_text, "")
local hit_tok = _safe_str(spec.label_tok, "")
if display_text == "" then return end
 
local has_def = _has_pipe(hit_tok)
local pill = parent:tag("span")
:addClass("sv-pill")
:addClass("sv-pill--value")
:addClass("sv-mech-keyword-pill")
 
if has_def then pill:addClass("sv-hover-lift") end
 
pill:tag("span")
:addClass("sv-mech-keyword-pill__label")
:wikitext(mw.text.nowiki(display_text))
 
if has_def then
_build_hitbox(pill, "sv-mech-keyword-pill__hit", frame, hit_tok)
end
end
end


local function _build_tabs(root_id, tabs_obj, level)
local function _build_tabs(frame, root_id, tabs_obj, level)
tabs_obj = _safe_tbl(tabs_obj)
tabs_obj = _safe_tbl(tabs_obj)


-- Support either payload.tabs.{...} or top-level {...} keys.
local mechanics = _safe_tbl(tabs_obj.mechanics)
local mechanics = _safe_tbl(tabs_obj.mechanics or tabs_obj.Mechanics or tabs_obj["mechanics"])
local effects  = _safe_tbl(tabs_obj.effects)
local keywords  = _safe_tbl(tabs_obj.keywords  or tabs_obj.Keywords  or tabs_obj["keywords"])
local effects  = _safe_tbl(tabs_obj.effects   or tabs_obj.Effects  or tabs_obj["effects"])
local events    = _safe_tbl(tabs_obj.events    or tabs_obj.Events    or tabs_obj["events"])


local mechanics_mods = _safe_tbl(mechanics.mods)
local mechanics_keywords = _safe_tbl(mechanics.keywords)
local effects_cards = _safe_tbl(effects.cards)
local effects_cards = _safe_tbl(effects.cards)
local events_cards  = _safe_tbl(events.cards)


local effects_count = _to_int(effects.count, #effects_cards)
if #mechanics_mods == 0 then
local events_count  = _to_int(events.count, #events_cards)
mechanics_mods = _safe_tbl(mechanics.grid)
end
if #mechanics_keywords == 0 then
local old_keywords = _safe_tbl(tabs_obj.keywords)
mechanics_keywords = _safe_tbl(old_keywords.pills)
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
table.insert(merged, card)
end
for _, card in ipairs(_safe_tbl(old_events.cards)) do
table.insert(merged, card)
end
 
effects_cards = merged
end
 
local mechanics_count = _to_int(
mechanics.count,
#mechanics_mods + #mechanics_keywords
)
 
local effects_count = _to_int(
effects.count,
#effects_cards
)


-- Dynamic 2–4 tab support (Skills defaults to 4).
local tab_specs = {
local tab_specs = {
{ key = "mechanics", title = "Mechanics" },
{ key = "mechanics", title = "Mechanics (" .. tostring(mechanics_count) .. ")" },
{ key = "keywords",  title = "Keywords"  },
{ key = "effects",  title = "Effects (" .. tostring(effects_count) .. ")" },
{ key = "effects",  title = "Effects (" .. tostring(effects_count) .. ")" },
{ key = "events",    title = "Events (" .. tostring(events_count) .. ")" },
}
}


local root = mw.html.create("div"):addClass("sv-skill-tabs")
local root = mw.html.create("div"):addClass("sv-skill-tabs")
local tabs = root:tag("div")
local tabs = root:tag("div")
:addClass("sv-tabs")
:addClass("sv-tabs")
Line 481: Line 1,356:
local panels = tabs:tag("div"):addClass("sv-tabs-panels")
local panels = tabs:tag("div"):addClass("sv-tabs-panels")


local active_idx = 1
for i, spec in ipairs(tab_specs) do
local active = (i == 1)


-- Buttons
list:tag("span")
for i, spec in ipairs(tab_specs) do
local tab_id, panel_id = _tab_ids(root_id, i)
local btn = list:tag("button")
:addClass("sv-tab")
:addClass("sv-tab")
:attr("type", "button")
:attr("role", "tab")
:attr("role", "tab")
:attr("id", tab_id)
:attr("tabindex", active and "0" or "-1")
:attr("aria-controls", panel_id)
:attr("data-tab", spec.key)
 
:attr("aria-selected", active and "true" or "false")
if i == active_idx then
:wikitext(mw.text.nowiki(spec.title))
btn:attr("aria-selected", "true"):attr("tabindex", "0")
else
btn:attr("aria-selected", "false"):attr("tabindex", "-1")
end
 
btn:wikitext(mw.text.nowiki(spec.title))
end
 
-- Panel 1: Mechanics
do
local tab_id, panel_id = _tab_ids(root_id, 1)
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:attr("role", "tabpanel")
:attr("id", panel_id)
:attr("aria-labelledby", tab_id)
:attr("data-active", active_idx == 1 and "1" or "0")
if active_idx ~= 1 then panel:attr("hidden", "hidden") end
 
local gridwrap = panel:tag("div"):addClass("sv-kw-root"):tag("div"):addClass("sv-kw-grid")
for _, item in ipairs(_safe_tbl(mechanics.grid)) do
item = _safe_tbl(item)
local cell = gridwrap:tag("div"):addClass("sv-kw-cell")
cell:tag("div"):addClass("sv-kw-label"):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
 
-- Panel 2: Keywords
do
local tab_id, panel_id = _tab_ids(root_id, 2)
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:attr("role", "tabpanel")
:attr("id", panel_id)
:attr("aria-labelledby", tab_id)
:attr("data-active", active_idx == 2 and "1" or "0")
if active_idx ~= 2 then panel:attr("hidden", "hidden") end
 
local pills = panel:tag("div"):addClass("sv-tab-pills")
for _, kw in ipairs(_safe_tbl(keywords.pills)) do
local s = _safe_str(kw, "")
if s ~= "" then
-- Definitions already exist; we just wrap them in a pill bubble.
pills:tag("div"):addClass("sv-pill"):wikitext("{{def|" .. s .. "}}")
end
end
end
end


-- Helpers for Effects/Events cards
local function render_ref_icon(parent, icon)
local function render_ref_icon(parent, icon)
icon = _safe_tbl(icon)
icon = _safe_tbl(icon)
Line 556: Line 1,379:
end
end


local function render_effect_card(parent, card)
local function render_effect_stats(parent, stats, kind)
card = _safe_tbl(card)
stats = _safe_tbl(stats)
kind = _safe_str(kind, ""):lower()


local title = _safe_str(card.title, "—")
if kind == "event" then
local page = _safe_str(card.page, "")
local sub = parent:tag("div"):addClass("sv-ref-sub")
local icon = _safe_tbl(card.icon)
if stats.text ~= nil then
 
_apply_value(sub, stats, level)
local container
else
if page ~= "" then
_apply_value(sub, { text = "" }, level)
local t = mw.title.new(page)
end
local href = t and t.localUrl and t:localUrl() or nil
container = parent:tag("a"):addClass("sv-ref-card"):addClass("sv-ref-card--link")
if href then container:attr("href", href) end
else
container = parent:tag("div")
:addClass("sv-ref-card")
:addClass("sv-ref-card--nolink")
:attr("tabindex", "0")
:attr("role", "group")
:attr("aria-label", title)
end
 
local ico = container:tag("div"):addClass("sv-ref-ico")
render_ref_icon(ico, icon)
 
local text = container:tag("div"):addClass("sv-ref-text")
 
if card.inline ~= nil then
local row = text:tag("div"):addClass("sv-ref-title-row")
row:tag("div"):addClass("sv-ref-title"):wikitext(mw.text.nowiki(title))
local inline = row:tag("div"):addClass("sv-ref-inline")
_apply_value(inline, card.inline, level)
return
return
end
end


text:tag("div"):addClass("sv-ref-title"):wikitext(mw.text.nowiki(title))
if next(stats) ~= nil then
local srow = parent:tag("div"):addClass("sv-ref-stats")


local stats = card.stats
local d = srow:tag("span")
if type(stats) == "table" and next(stats) ~= nil then
:addClass("sv-pill")
local srow = text:tag("div"):addClass("sv-ref-stats")
:addClass("sv-pill--value")
local d = srow:tag("span"):addClass("sv-ref-stat")
:addClass("sv-ref-stat")
_apply_value(d, stats.duration, level)
_apply_value(d, stats.duration, level)
local c = srow:tag("span"):addClass("sv-ref-stat")
 
local c = srow:tag("span")
:addClass("sv-pill")
:addClass("sv-pill--value")
:addClass("sv-ref-stat")
_apply_value(c, stats.chance, level)
_apply_value(c, stats.chance, level)
local st = srow:tag("span"):addClass("sv-ref-stat")
 
local st = srow:tag("span")
:addClass("sv-pill")
:addClass("sv-pill--value")
:addClass("sv-ref-stat")
_apply_value(st, stats.stacks, level)
_apply_value(st, stats.stacks, level)
end
end
end
end


local function render_event_card(parent, card)
local function render_ref_card(parent, card)
card = _safe_tbl(card)
card = _safe_tbl(card)


local kind = _safe_str(card.kind, "")
local title = _safe_str(card.title, "—")
local title = _safe_str(card.title, "—")
local page = _safe_str(card.page, "")
local page = _safe_str(card.page, "")
local icon = _safe_tbl(card.icon)
local icon = _safe_tbl(card.icon)


local container
local container = parent:tag("div")
if page ~= "" then
:addClass("sv-tile")
local t = mw.title.new(page)
:addClass("sv-hover-lift")
local href = t and t.localUrl and t:localUrl() or nil
:addClass("sv-ref-card")
container = parent:tag("a"):addClass("sv-ref-card"):addClass("sv-ref-card--link")
if kind ~= "" then
if href then container:attr("href", href) end
container:addClass("sv-ref-card--" .. _slugify(kind))
else
container = parent:tag("div")
:addClass("sv-ref-card")
:addClass("sv-ref-card--nolink")
:attr("tabindex", "0")
:attr("role", "group")
:attr("aria-label", title)
end
end


local ico = container:tag("div"):addClass("sv-ref-ico")
local ico = container:tag("div")
:addClass("sv-ref-ico")
:addClass("sv-tile")
render_ref_icon(ico, icon)
render_ref_icon(ico, icon)


local text = container:tag("div"):addClass("sv-ref-text")
local text = container:tag("div"):addClass("sv-ref-text")
local row = text:tag("div"):addClass("sv-ref-title-row"):addClass("sv-ref-title-row--stacked")
local title_div = text:tag("div"):addClass("sv-ref-title")
row:tag("div"):addClass("sv-ref-title"):wikitext(mw.text.nowiki(title))
if page ~= "" then
local sub = row:tag("div"):addClass("sv-ref-sub")
title_div:wikitext(string.format("[[%s|%s]]", page, mw.text.nowiki(title)))
_apply_value(sub, card.type, level)
else
title_div:wikitext(mw.text.nowiki(title))
end
 
render_effect_stats(text, card.stats, kind)
end
end


-- Panel 3: Effects
do
do
local tab_id, panel_id = _tab_ids(root_id, 3)
local panel = panels:tag("div")
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:addClass("sv-tabpanel")
:addClass("sv-mech-panel")
:attr("role", "tabpanel")
:attr("role", "tabpanel")
:attr("id", panel_id)
:attr("data-panel", "mechanics")
:attr("aria-labelledby", tab_id)
 
:attr("data-active", active_idx == 3 and "1" or "0")
local mods_wrap = panel:tag("div")
if active_idx ~= 3 then panel:attr("hidden", "hidden") end
:addClass("sv-mech-panel__group")
:addClass("sv-mech-panel__group--mods")
:addClass("sv-mech-panel__mods")
 
local keys_wrap = panel:tag("div")
:addClass("sv-mech-panel__group")
:addClass("sv-mech-panel__group--keywords")
:addClass("sv-mech-panel__keywords")
 
if #mechanics_mods > 0 then
mods_wrap:tag("div"):addClass("sv-tab-section-title"):wikitext("Modifiers")
 
local gridwrap = mods_wrap:tag("div"):addClass("sv-mech-mod-grid")
for _, item in ipairs(mechanics_mods) do
_build_modifier_pill(frame, gridwrap, item, level)
end
end


local grid = panel:tag("div"):addClass("sv-ref-grid")
if #mechanics_keywords > 0 then
for _, card in ipairs(effects_cards) do
keys_wrap:tag("div"):addClass("sv-tab-section-title"):wikitext("Keywords")
render_effect_card(grid, card)
 
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


-- Panel 4: Events
do
do
local tab_id, panel_id = _tab_ids(root_id, 4)
local panel = panels:tag("div")
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:addClass("sv-tabpanel")
:addClass("sv-hidden")
:attr("role", "tabpanel")
:attr("role", "tabpanel")
:attr("id", panel_id)
:attr("data-panel", "effects")
:attr("aria-labelledby", tab_id)
:attr("hidden", "hidden")
:attr("data-active", active_idx == 4 and "1" or "0")
if active_idx ~= 4 then panel:attr("hidden", "hidden") end


local grid = panel:tag("div"):addClass("sv-ref-grid")
if #effects_cards > 0 then
for _, card in ipairs(events_cards) do
local grid = panel:tag("div"):addClass("sv-ref-grid")
render_event_card(grid, card)
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
Line 674: Line 1,515:
end
end


-- Public entrypoint:
local function _map_to_groups(map_tbl, preferred_order)
-- {{#invoke:GameInfo/Skills|render|data=...|notes=...|id=...}}
local m = _safe_tbl(map_tbl)
function p.render(frame)
 
frame = frame or mw.getCurrentFrame()
local function normalize_items(v)
local args = frame.args or {}
if type(v) == "table" then
return v
end
if v == nil then
return {}
end
return { v }
end
 
local used = {}
local group_keys = {}
 
local function push_key(k)
k = tostring(k)
if k ~= "" and not used[k] and m[k] ~= nil then
used[k] = true
table.insert(group_keys, k)
end
end
 
local explicit = m.__order or m.order
if type(explicit) == "table" then
for _, k in ipairs(explicit) do
push_key(k)
end
end
 
if type(preferred_order) == "table" then
for _, k in ipairs(preferred_order) do
push_key(k)
end
end
 
local rest = {}
for k, _ in pairs(m) do
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 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
 
local row = mw.html.create("div"):addClass("sv-reqrow")
if req_box then row:node(req_box) end
if usr_box then row:node(usr_box) end
return row
end
 
local function _build_req_users(root_id, requirements, users)
requirements = _safe_tbl(requirements)
users = _safe_tbl(users)
 
local req_box, req_n = _build_grouped_disclose(
root_id, "req", "Requirements", _safe_tbl(requirements.groups)
)
local usr_box, usr_n = _build_grouped_disclose(
root_id, "usr", "Users", _safe_tbl(users.groups)
)
 
if req_n == 0 and usr_n == 0 then return nil end
 
local row = mw.html.create("div"):addClass("sv-reqrow")
if req_box then row:node(req_box) end
if usr_box then row:node(usr_box) end
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


local payload, err = _decode_json(args.data)
if count == 1 then
if not payload then
wrap:addClass("sv-skill-meta-block--single")
local msg = mw.html.create("div")
:addClass("sv-gi-error")
:wikitext("GameInfo/Skills error: " .. tostring(err))
return GI.styles(frame) .. tostring(msg)
end
end


return wrap
end
local function _render_legacy(frame, payload, notes_wt)
local identity = _safe_tbl(payload.identity)
local identity = _safe_tbl(payload.identity)
local display_name = _safe_str(identity.display_name, "Unknown Skill")
local display_name = _safe_str(identity.display_name, "Unknown Skill")


local idx = _to_int(args.index, 1)
local idx = _to_int(GI.arg(frame, "index", "1"), 1)
if idx < 1 then idx = 1 end
if idx < 1 then idx = 1 end


local root_id = _safe_str(args.id, "")
local root_id = GI.arg(frame, "id", "")
if root_id == "" then
if root_id == "" then
root_id = "sv-skill-" .. _slugify(display_name) .. "-" .. tostring(idx)
root_id = "sv-skill-" .. _slugify(display_name) .. "-" .. tostring(idx)
end
end


local level_obj = _safe_tbl(payload.level)
local default_level, _base_max, max_level = _normalize_level(payload.level)
local default_level = _to_int(level_obj.default, 1)
local max_level = _to_int(level_obj.max, _to_int(level_obj.base_max, 1))
 
if max_level < 1 then max_level = 1 end
if default_level < 1 then default_level = 1 end
if default_level > max_level then default_level = max_level end


-- Build base box
local box = GI.box({
local box = GI.new_box({
root_id = root_id,
root_id = root_id,
level = default_level,
level = default_level,
Line 715: Line 1,662:
})
})


-- Compatibility / Skills classes (match mockup)
box.root:addClass("sv-skill-card")
box.root:addClass("sv-skill-card")
box.top:addClass("sv-skill-top")
box.top:addClass("sv-skill-top")
box.bottom:addClass("sv-skill-bottom")
box.bottom:addClass("sv-skill-bottom")


-- TOP: identity + meta + req/users
box.top:node(_build_identity(frame, root_id, identity, notes_wt))
box.top:node(_build_identity(identity, args.notes))
box.top:node(_build_meta_row(payload.meta_row))


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


-- BOTTOM: level + scaling + core + tabs
local level_panel, actual_default = _build_level(root_id, default_level, max_level, 1, nil)
local level_panel, actual_default = _build_level(payload.level)
box.bottom:node(level_panel)
box.bottom:node(level_panel)
box.bottom:node(_build_scaling_top(payload.scaling_top, actual_default))
box.bottom:node(_build_scaling_top(frame, payload.scaling_top, actual_default))
box.bottom:node(_build_core_stats(payload.core_stats, actual_default))
box.bottom:node(_build_core_stats(payload.core_stats, actual_default))
box.bottom:node(_build_tabs(frame, root_id, payload.tabs, actual_default))
return tostring(box.root)
end
local function _render_native(frame, payload, notes_wt)
local identity = _safe_tbl(payload.identity)
local display_name = _safe_str(identity.display_name, "Unknown Skill")


local tabs_obj = _safe_tbl(payload.tabs)
local idx = _to_int(GI.arg(frame, "index", "1"), 1)
if next(tabs_obj) == nil then tabs_obj = payload end
if idx < 1 then idx = 1 end
box.bottom:node(_build_tabs(root_id, tabs_obj, actual_default))
 
local root_id = GI.arg(frame, "id", "")
if root_id == "" then
root_id = "sv-skill-" .. _slugify(display_name) .. "-" .. tostring(idx)
end
 
local min_level, default_level, max_level = _normalize_level_native(payload.level)
 
local box = GI.box({
root_id = root_id,
level = default_level,
max_level = max_level,
variant = "skills",
})
 
box.root:addClass("sv-skill-card")
box.top:addClass("sv-skill-top")
box.bottom:addClass("sv-skill-bottom")
 
box.root:attr("data-level-default", tostring(default_level))
do
local oc = _safe_tbl(_safe_tbl(payload.level).overcap)
if next(oc) ~= nil then
local safe_oc = { max = _to_int(oc.max, max_level), sources = _safe_tbl(oc.sources) }
box.root:attr("data-level-overcap", mw.text.jsonEncode(safe_oc))
end
end
 
box.top:node(_build_identity(frame, root_id, identity, notes_wt))
 
local meta_block = _build_meta_row_native(frame, payload.skill_meta)
local reqrow = _build_req_users_native(root_id, payload.requirements, payload.users)
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(
frame,
root_id,
payload.skill_scaling,
default_level,
max_level,
min_level,
_safe_tbl(payload.level).overcap
)
 
box.bottom:node(scaling_block)
box.bottom:node(_build_tabs(frame, root_id, payload.tabs, actual_default))
 
return tostring(box.root)
end
 
function p.render(frame)
frame = frame or mw.getCurrentFrame()
 
local notes_wt = GI.arg(frame, "notes", "")
local payload, err, preview = _decode_payload(frame)
if not payload then
local debug = (GI.arg(frame, "debug", "0") == "1")
return _err(err, debug and preview or nil)
end
 
if payload.schema == 2 then
return _render_native(frame, payload, notes_wt)
end


return GI.styles(frame) .. tostring(box.root)
return _render_legacy(frame, payload, notes_wt)
end
end


return p
return p