Module:GameInfo/Skills
More actions
Module:GameInfo/Skills renders the compiled “Skill card” payload used by Module:GameInfo.
Overview
This module is a strict renderer for a compiled JSON payload (schema 1). It does not attempt compatibility fallbacks or schema guessing.
Where it is used
This module is loaded by Module:GameInfo and called through the GameInfo entry points (for example the Skills route).
Styling
This module declares a TemplateStyles source so the wrapper can load the correct CSS:
STYLE_SRC = "Module:GameInfo/styles.css"
Inputs
Arguments are read from frame.args (handled by Module:GameInfo).
Required:
data— JSON string payload (must decode to an object and containschema=1)
Optional:
id— HTML id for the root card. If omitted, one is generated from display name + index.index— Used only for generating a stable id. Default1.notes— Wikitext content shown in the Notes popup.debug— If set to1, error output includes a nowiki preview of the first ~480 chars of the raw JSON.
Example usage (shown as text only):
{{#invoke:GameInfo|Skills|id=...|index=...|notes=...|data=...}}
Payload expectations (schema 1)
The payload is expected to contain the parts needed to build the full Skill card, including:
identity(name/description/sprite)level(default/max)meta_row(four meta cells)requirementsandusers(optional grouped lists)scaling_top,core_statstabs(mechanics, keywords, effects, events)
Values may be either:
- Plain strings/numbers, or
- Objects like
{"text":"..."}, or - Series objects like
{"series":[...]}(used for level-based selection and emitted asdata-series)
Images and missing files
File pages are checked with a small existence cache. If a file page is missing, the renderer outputs a small “?” badge (sv-miss) instead of a redlink image.
Keyword pills
Keyword pill strings in the form Domain|Key are expanded to the Definitions template:
{{def|Domain|Key}}
Other strings are rendered as plain text.
Interactive UI hooks
The module outputs semantic hooks used by site JS:
- Popups use
data-sv-toggleandsv-hidden - Tabs use
data-tabsanddata-tabs-root - The custom level slider uses
data-sv-sliderand ARIA slider attributes
-- Module:GameInfo/Skills
-- Phase 4.1 — Native Schema 2 rendering (Schema 1 + Schema 2, NO adapter glue)
--
-- DEFINITIONS POLICY (strict):
-- - Only accept Definition tokens as JSON strings using \u007C, e.g. "Damage\u007CPiercing".
-- - After jsonDecode, this becomes "Damage|Piercing" and is expanded via {{def|Damage|Piercing}}.
-- - No support for:
-- * <nowiki>...</nowiki> wrapped |data=
-- * double-escaped pipes (\\u007C)
-- * 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).
-- - Stat scaling (skill_scaling.stat_scaling) renders WITH icons.
--
-- 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):
-- - Schema2 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).
local p = {}
local GI = require("Module:GameInfo")
p.STYLE_SRC = "Module:GameInfo/styles.css"
local _file_exists_cache = {}
local function _trim(s)
if s == nil then return "" end
s = tostring(s)
return (mw.text and mw.text.trim) and mw.text.trim(s)
or (s:gsub("^%s+", ""):gsub("%s+$", ""))
end
local function _safe_tbl(t) return type(t) == "table" and t or {} end
local function _safe_str(s, fallback)
s = _trim(s)
if s == "" then return fallback or "" end
return s
end
local function _to_int(v, fallback)
local n = tonumber(v)
if not n then return fallback end
return math.floor(n + 0.0)
end
local function _slugify(s)
s = _trim(s):lower()
s = s:gsub("[^%w]+", "-"):gsub("%-+", "-"):gsub("^%-", ""):gsub("%-$", "")
if s == "" then s = "item" end
return s
end
local function _err(msg, preview)
local box = mw.html.create("div"):addClass("sv-gi-error")
box:wikitext("GameInfo/Skills error: " .. tostring(msg))
if preview and preview ~= "" then
box:tag("pre"):wikitext(mw.text.nowiki(preview:sub(1, 480)))
end
return tostring(box)
end
local function _decode_payload(frame)
local raw = GI.arg(frame, "data", "")
raw = _trim(raw)
if raw == "" then return nil, "missing |data=", "" end
local ok, decoded = pcall(mw.text.jsonDecode, raw)
if not ok then
return nil, "invalid json", raw
end
if type(decoded) ~= "table" then
return nil, "json is not object", raw
end
local schema = decoded.schema
if schema ~= 1 and schema ~= 2 then
return nil, "unsupported schema=" .. tostring(schema), raw
end
return decoded, nil, ""
end
local function _normalize_file_title(page)
page = _trim(page)
if page == "" then return nil end
if page:match("^[Ff]ile:") or page:match("^[Ii]mage:") then
page = page:gsub("^[Ii]mage:", "File:"):gsub("^[Ff]ile:", "File:")
return page
end
return "File:" .. page
end
local function _file_exists(file_title)
if not file_title then return false end
local cached = _file_exists_cache[file_title]
if cached ~= nil then return cached end
local exists = false
local t = mw.title.new(file_title)
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
return exists
end
local function _question_badge()
return mw.html.create("span")
:addClass("sv-miss")
:attr("aria-hidden", "true")
:wikitext("?")
end
local function _render_file_image(file_page, alt, size_px)
local title = _normalize_file_title(file_page)
if not title or not _file_exists(title) then
return _question_badge()
end
local size = tostring(size_px or 48) .. "px"
return mw.html.create("span")
:addClass("sv-img")
:wikitext(string.format(
"[[%s|%s|link=|alt=%s]]",
title,
size,
mw.text.nowiki(_safe_str(alt, ""))
))
end
local function _apply_value(node, val, level)
if val == nil then
node:wikitext("—")
return
end
if type(val) ~= "table" then
node:wikitext(mw.text.nowiki(tostring(val)))
return
end
local series = val.series
if type(series) == "table" and #series > 0 then
node:attr("data-series", mw.text.jsonEncode(series))
local v = series[level] or series[#series]
if v == nil then v = "—" end
node:wikitext(mw.text.nowiki(tostring(v)))
return
end
if val.text ~= nil then
node:wikitext(mw.text.nowiki(tostring(val.text)))
return
end
node:wikitext("—")
end
-- Strict def token renderer (Domain|Key only).
-- IMPORTANT: s must already contain a real pipe (|). (Bot uses \u007C, jsonDecode turns it into |.)
local function _has_pipe(s)
s = _trim(s)
return s ~= "" and s:find("|", 1, true) ~= nil
end
local function _render_def_token(frame, s, noicon, extra_args)
s = _trim(s)
if s == "" then return nil end
local bar = s:find("|", 1, true)
if not bar then
return mw.text.nowiki(s)
end
local domain = _trim(s:sub(1, bar - 1))
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
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 _meta_lines_plain(lines)
lines = _safe_tbl(lines)
local wrap = mw.html.create("span"):addClass("sv-meta-lines")
local any = false
for _, ln in ipairs(lines) do
local s = _trim(ln)
if s ~= "" then
wrap:tag("span"):wikitext(mw.text.nowiki(s))
any = true
end
end
if not any then
wrap:tag("span"):wikitext("—")
end
return wrap
end
local function _meta_lines_def(frame, lines)
lines = _safe_tbl(lines)
local wrap = mw.html.create("span"):addClass("sv-meta-lines")
local any = false
for _, ln in ipairs(lines) do
local s = _trim(ln)
if s ~= "" then
-- Meta row has its own icon, so force noicon=1 for definition text.
local wt = _render_def_token(frame, s, true) or mw.text.nowiki(s)
wrap:tag("span"):wikitext(wt)
any = true
end
end
if not any then
wrap:tag("span"):wikitext("—")
end
return wrap
end
-- Keywords pill policy: ONLY Domain|Key tokens (from Domain\u007CKey in JSON).
-- Pills should NOT render definition icons.
local function _render_keyword_pill(frame, pill_text)
pill_text = _trim(pill_text)
if pill_text == "" then
return nil
end
-- Force noicon=1 so Definition icons never appear in pills.
local wt = _render_def_token(frame, pill_text, true)
if wt then return wt end
return mw.text.nowiki(pill_text)
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
-- Normalize authoring pitfall: leading spaces before list markers (* / #)
-- turn those lines into preformatted blocks, so lists won’t render.
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
-- Single-level list parsing (most common Notes shape).
-- We intentionally do not attempt nested list reconstruction here.
local mark = ln:sub(1, 1)
local tagname = (mark == "#") and "ol" or "ul"
-- Collect contiguous list lines with the same leading marker.
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
-- Keep wikitext inside li so links/templates still work.
list:tag("li"):wikitext(txt)
end
wrote_any = true
end
else
-- Paragraph-ish block: accumulate until blank line or list begins.
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
-- Universal Popups source: notes tip (no legacy hint nodes).
local function _build_notes_tip(frame, root_id, notes_wt)
notes_wt = _trim(notes_wt)
if notes_wt == "" then return nil end
local tip_id = root_id .. "-notes"
local wrap = mw.html.create("div"):addClass("sv-tip")
local btn = wrap:tag("span")
:addClass("sv-tip-btn")
:attr("role", "button")
:attr("tabindex", "0")
:attr("data-sv-toggle", "1")
-- Explicit Universal Popups behavior
: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")
:addClass("sv-ico")
:addClass("sv-ico--info")
:attr("aria-hidden", "true")
:wikitext("i")
local pop = wrap:tag("div")
:addClass("sv-tip-pop")
:addClass("sv-hidden")
:attr("hidden", "hidden")
:attr("id", tip_id)
:attr("aria-label", "Notes")
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)
return wrap
end
-- ----------------------------------------------------------------------------
-- OVERCAP (Schema 2): Option A (Universal Popups)
-- Goal:
-- - Show a compact trigger near the level UI: "Overcap +N"
-- - Popup lists sources (links) and the level bonus each provides.
-- - If a source omits levels and it's the only missing one, infer from (max-default).
-- - If multiple sources omit levels, show "(+?)" for those items.
-- - No new slider CSS required; uses existing sv-tip / sv-tip-pop styling + Common.js popups.
-- ----------------------------------------------------------------------------
local function _normalize_overcap_sources(sources, total_bonus)
sources = _safe_tbl(sources)
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
-- Accept {page="X", levels=2} and also array-ish { "X", 2 }
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 remaining = (_to_int(total_bonus, 0) or 0) - explicit_sum
if remaining < 0 then remaining = 0 end
-- If exactly one source is missing levels, infer it from the remaining bonus.
if missing == 1 and remaining > 0 then
for _, it in ipairs(out) do
if it.levels == nil then
it.levels = remaining
break
end
end
end
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)
-- If no sources provided, still show the badge, but mark unknown.
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-overcap-btn")
:attr("role", "button")
:attr("tabindex", "0")
:attr("data-sv-toggle", "1")
-- Click-only near slider (avoid hover spam while dragging)
: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")
-- Summary line (always helpful)
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")
if show_unknown then
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
if it.levels ~= nil and it.levels > 0 then
li:wikitext(mw.text.nowiki(" (+" .. tostring(it.levels) .. ")"))
else
-- Multiple sources but missing per-source split
li:wikitext(mw.text.nowiki(" (+?)"))
end
end
end
return wrap
end
-- ----------------------------------------------------------------------------
-- REQUIREMENTS / USERS (refined)
-- Goal:
-- - Popup body is sections: Header + comma-separated inline list.
-- - Each list item can optionally be linked.
-- - Schema2 supports:
-- * string -> plain text (no link)
-- * ["Name"] or ["Name","(suffix)"] -> link inferred to Name
-- * {text,page,suffix} (future-proof)
-- - Legacy schema1 still supported via {label_lines, link={page=...}}
-- ----------------------------------------------------------------------------
local function _normalize_disclose_item(it)
-- Returns { text=..., page=nil|"...", suffix=nil|"..."} or nil
if type(it) == "string" then
local s = _trim(it)
if s == "" then return nil end
return { text = s }
end
if type(it) ~= "table" then return nil end
-- Explicit object form (future-proof for bot):
-- { text="Paladin", page="Paladin", suffix="(Lv 1)" }
if it.text ~= nil or it.page ~= nil or it.suffix ~= nil or it.name ~= nil or it.label ~= nil then
local text = _safe_str(it.text or it.label or it.name or "", "")
if text == "" then return nil end
local page = _safe_str(it.page or "", "")
local suffix = _safe_str(it.suffix or "", "")
return { text = text, page = (page ~= "" and page or nil), suffix = (suffix ~= "" and suffix or nil) }
end
-- Array form:
-- ["Sacrifice","(Lv 1)"] -> link to Sacrifice, suffix shown
-- ["Paladin"] -> link to Paladin
if #it >= 1 then
local name = _safe_str(it[1], "")
if name == "" then return nil end
local suffix = _safe_str(it[2], "")
return {
text = name,
page = name, -- inferred link
suffix = (suffix ~= "" and suffix or nil)
}
end
-- Legacy schema1 internal form: {label_lines={...}, link={page="..."}}.
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
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
local function _count_group_items(groups)
local n = 0
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
-- Universal Popups source: disclose popover (refined body; no legacy hint nodes).
local function _build_grouped_disclose(root_id, key, label, groups)
local total = _count_group_items(groups)
if total == 0 then return nil, 0 end
local pop_id = root_id .. "-" .. key
local wrap = mw.html.create("div"):addClass("sv-disclose")
local btn = wrap:tag("span")
:addClass("sv-disclose-btn")
:attr("role", "button")
:attr("tabindex", "0")
:attr("data-sv-toggle", "1")
-- Explicit Universal Popups behavior
:attr("data-sv-pop", "click")
:attr("data-sv-pop-size", "sm")
: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))
btn:tag("span"):addClass("sv-disclose-count"):wikitext(" (" .. tostring(total) .. ")")
local pop = wrap:tag("div")
:addClass("sv-disclose-pop")
:addClass("sv-hidden")
:attr("hidden", "hidden")
:attr("id", pop_id)
:attr("aria-label", label)
local head = pop:tag("div"):addClass("sv-disclose-pop-head")
head:tag("div"):addClass("sv-disclose-pop-title"):wikitext(mw.text.nowiki(label))
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
-- Ensure visual spacing: "Name (Lv X)" even if suffix is "(Lv X)"
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
local title = _safe_str(g.title, "—")
local items = _safe_tbl(g.items)
if #items > 0 then
local sec = secs:tag("div"):addClass("sv-disclose-sec")
sec:tag("div"):addClass("sv-disclose-sec-title"):wikitext(mw.text.nowiki(title))
local line = sec:tag("div"):addClass("sv-disclose-sec-items")
local norms = {}
for _, it in ipairs(items) do
local norm = _normalize_disclose_item(it)
if norm then table.insert(norms, norm) end
end
if #norms == 0 then
line:tag("span"):addClass("sv-disclose-item"):wikitext("—")
else
for i, norm in ipairs(norms) do
render_item(line, norm)
if i < #norms then line:wikitext(", ") end
end
end
end
end
return wrap, total
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 _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")
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
-- Schema 1 meta_row renderer (unchanged)
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 card = meta:tag("div"):addClass("sv-meta-card")
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")
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)
if #lines > 0 then
text:node(_meta_lines_def(frame, lines))
else
text:wikitext("—")
end
-- Full-pill Definitions hitbox (use first def token line if present)
local hit_tok = ""
for _, ln in ipairs(lines) do
local s = _safe_str(ln, "")
if _has_pipe(s) then
hit_tok = s
break
end
end
if 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
return meta
end
-- Schema 2 meta renderer (native)
local function _build_meta_row_schema2(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 card = meta:tag("div"):addClass("sv-meta-card")
local icon_div = card:tag("div"):addClass("sv-meta-icon")
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
local wrap = card:tag("div"):addClass("sv-meta-textwrap")
local text = wrap:tag("div"):addClass("sv-meta-text")
-- Display: prefer label_lines (wrap control / qualifiers); fallback to label_wt
local display_lines = _safe_tbl(cell.label_lines)
if #display_lines == 0 then
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
-- Interaction: full-pill Definitions hitbox uses label_wt (canonical token)
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
return meta
end
-- Level normalization (Schema 1 internal):
local function _normalize_level(level_obj)
level_obj = _safe_tbl(level_obj)
local max = _to_int(level_obj.max, nil)
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 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
-- Schema 2 level normalization (native)
local function _normalize_level_schema2(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
local root = mw.html.create("div"):addClass("sv-skill-level")
local ui = root:tag("div"):addClass("sv-level-ui")
local label = ui:tag("div"):addClass("sv-level-label")
label:wikitext("Level ")
label:tag("span"):addClass("sv-level-num"):wikitext(tostring(default))
label:wikitext(" / ")
label:tag("span"):addClass("sv-level-max"):wikitext(tostring(max))
-- Overcap badge + popup (Schema 2)
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
-- Defensive fallback (in case a future schema sets max>default but omits overcap object)
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 = slider_wrap:tag("span")
:addClass("sv-level-range")
:addClass("sv-level-range--custom")
:attr("data-sv-slider", "1")
:attr("role", "slider")
:attr("tabindex", "0")
:attr("aria-label", "Skill level select")
: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_wrap:tag("div"):addClass("sv-level-ticklabels"):attr("aria-hidden", "true")
return root, default, max, min
end
-- UPDATED (kept): supports stat_scaling (Defs WITH icons) + fallback scaling_lines
local function _build_scaling_top(frame, scaling, level)
scaling = _safe_tbl(scaling)
local root = mw.html.create("div"):addClass("sv-skill-scaling")
local row = root:tag("div"):addClass("sv-scaling-row")
local grid = row:tag("div"):addClass("sv-scaling-grid")
do
local col = grid:tag("div"):addClass("sv-scaling-col")
local v = col:tag("div"):addClass("sv-scaling-value")
_apply_value(v, scaling.damage, level)
col:tag("div"):addClass("sv-scaling-label"):wikitext("Damage")
end
do
local col = grid:tag("div"):addClass("sv-scaling-col sv-scaling-col--scaling")
local list = col:tag("div"):addClass("sv-scaling-list")
for _, it in ipairs(_safe_tbl(scaling.stat_scaling)) do
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-scaling-item 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
for _, ln in ipairs(_safe_tbl(scaling.scaling_lines)) do
local item = list:tag("div"):addClass("sv-scaling-item")
_apply_value(item, ln, level)
end
end
return root
end
-- Schema 2 scaling top (native; same DOM/classes)
local function _build_scaling_top_schema2(frame, skill_scaling, level)
skill_scaling = _safe_tbl(skill_scaling)
local dmg = _safe_tbl(skill_scaling.damage)
local dmg_label = _safe_str(dmg.label, "Damage")
local dmg_val = _safe_tbl(dmg.value)
local root = mw.html.create("div"):addClass("sv-skill-scaling")
local row = root:tag("div"):addClass("sv-scaling-row")
local grid = row:tag("div"):addClass("sv-scaling-grid")
do
local col = grid:tag("div"):addClass("sv-scaling-col")
local v = col:tag("div"):addClass("sv-scaling-value")
_apply_value(v, dmg_val, level)
col:tag("div"):addClass("sv-scaling-label"):wikitext(mw.text.nowiki(dmg_label))
end
do
local col = grid:tag("div"):addClass("sv-scaling-col sv-scaling-col--scaling")
local list = col:tag("div"):addClass("sv-scaling-list")
for _, it in ipairs(_safe_tbl(skill_scaling.stat_scaling)) do
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-scaling-item 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
end
return root
end
local function _build_core_stats(core_stats, level)
core_stats = _safe_tbl(core_stats)
local root = mw.html.create("div"):addClass("sv-skill-core")
local row = root:tag("div"):addClass("sv-core-row")
local grid = row:tag("div"):addClass("sv-core-grid")
for _, cell in ipairs(core_stats) do
cell = _safe_tbl(cell)
local c = grid:tag("div"):addClass("sv-core-cell")
local top = c:tag("div"):addClass("sv-core-top")
local num = top:tag("span"):addClass("sv-core-num")
_apply_value(num, cell.value, level)
local unit = _safe_str(cell.unit, "")
if unit ~= "" then
top:tag("span"):addClass("sv-core-unit"):wikitext(mw.text.nowiki(unit))
end
local key = _safe_str(cell.key, "")
local label = c:tag("div"):addClass("sv-core-label"):wikitext(mw.text.nowiki(key))
if key:len() >= 9 then label:addClass("sv-core-label--tight") end
end
return root
end
-- Schema 2 core stats (native; same DOM/classes)
local function _build_core_stats_schema2(skill_scaling, level)
skill_scaling = _safe_tbl(skill_scaling)
local function has_value_field(fld)
return type(fld) == "table" and type(fld.value) == "table"
end
local order = {
{ key = "Cost", field = "cost" },
{ key = "Cast Time", field = "cast_time" },
{ key = "Cooldown", field = "cooldown" },
{ key = "Range", field = "range" },
{ key = "Area", field = "area" },
{ key = "Duration", field = "duration" },
}
local root = mw.html.create("div"):addClass("sv-skill-core")
local row = root:tag("div"):addClass("sv-core-row")
local grid = row:tag("div"):addClass("sv-core-grid")
for _, spec in ipairs(order) do
local fld = _safe_tbl(skill_scaling[spec.field])
if has_value_field(fld) then
local c = grid:tag("div"):addClass("sv-core-cell")
local top = c:tag("div"):addClass("sv-core-top")
local num = top:tag("span"):addClass("sv-core-num")
_apply_value(num, fld.value, level)
local unit = _safe_str(fld.unit, "")
if unit ~= "" then
top:tag("span"):addClass("sv-core-unit"):wikitext(mw.text.nowiki(unit))
end
local label = c:tag("div"):addClass("sv-core-label"):wikitext(mw.text.nowiki(spec.key))
if spec.key:len() >= 9 then label:addClass("sv-core-label--tight") end
end
end
return root
end
-- Tabs renderer (already matches Schema 2 shape; used by both schemas)
local function _build_tabs(frame, root_id, tabs_obj, level)
tabs_obj = _safe_tbl(tabs_obj)
local mechanics = _safe_tbl(tabs_obj.mechanics)
local keywords = _safe_tbl(tabs_obj.keywords)
local effects = _safe_tbl(tabs_obj.effects)
local events = _safe_tbl(tabs_obj.events)
local effects_cards = _safe_tbl(effects.cards)
local events_cards = _safe_tbl(events.cards)
local effects_count = _to_int(effects.count, #effects_cards)
local events_count = _to_int(events.count, #events_cards)
local tab_specs = {
{ key = "mechanics", title = "Mechanics" },
{ key = "keywords", title = "Keywords" },
{ 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 tabs = root:tag("div")
:addClass("sv-tabs")
:attr("data-tabs", "1")
:attr("data-tabs-root", root_id)
local list = tabs:tag("div"):addClass("sv-tabs-list"):attr("role", "tablist")
local panels = tabs:tag("div"):addClass("sv-tabs-panels")
for i, spec in ipairs(tab_specs) do
local active = (i == 1)
list:tag("span")
:addClass("sv-tab")
:attr("role", "tab")
:attr("tabindex", active and "0" or "-1")
:attr("data-tab", spec.key)
:attr("aria-selected", active and "true" or "false")
:wikitext(mw.text.nowiki(spec.title))
end
do
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:attr("role", "tabpanel")
:attr("data-panel", "mechanics")
local gridwrap = panel: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
do
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:attr("role", "tabpanel")
:attr("data-panel", "keywords")
:attr("hidden", "hidden")
local pills = panel:tag("div"):addClass("sv-tab-pills")
for _, kw in ipairs(_safe_tbl(keywords.pills)) do
local wt = _render_keyword_pill(frame, kw)
if wt then
pills:tag("span"):addClass("sv-pill"):wikitext(wt)
end
end
end
local function render_ref_icon(parent, icon)
icon = _safe_tbl(icon)
local page = _safe_str(icon.page, "")
local alt = _safe_str(icon.alt, "")
if page ~= "" then parent:node(_render_file_image(page, alt, 52)) else parent:node(_question_badge()) end
end
local function render_ref_card(parent, card, is_event)
card = _safe_tbl(card)
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-ref-card")
local ico = container:tag("div"):addClass("sv-ref-ico")
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
title_div:wikitext(string.format("[[%s|%s]]", page, mw.text.nowiki(title)))
else
title_div:wikitext(mw.text.nowiki(title))
end
if is_event then
local sub = text:tag("div"):addClass("sv-ref-sub")
_apply_value(sub, card.type, level)
return
end
local stats = card.stats
if type(stats) == "table" and next(stats) ~= nil then
local srow = text:tag("div"):addClass("sv-ref-stats")
local d = srow:tag("span"):addClass("sv-ref-stat"); _apply_value(d, stats.duration, level)
local c = srow:tag("span"):addClass("sv-ref-stat"); _apply_value(c, stats.chance, level)
local st = srow:tag("span"):addClass("sv-ref-stat"); _apply_value(st, stats.stacks, level)
end
end
do
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:attr("role", "tabpanel")
:attr("data-panel", "effects")
:attr("hidden", "hidden")
local grid = panel:tag("div"):addClass("sv-ref-grid")
for _, card in ipairs(effects_cards) do
render_ref_card(grid, card, false)
end
end
do
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:attr("role", "tabpanel")
:attr("data-panel", "events")
:attr("hidden", "hidden")
local grid = panel:tag("div"):addClass("sv-ref-grid")
for _, card in ipairs(events_cards) do
render_ref_card(grid, card, true)
end
end
return root
end
-- Schema 2 map -> groups helper (dynamic headers + deterministic order)
local function _map_to_groups(map_tbl, preferred_order)
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
-- Defensive: allow a single string/number to become a 1-item list.
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
-- Explicit order (bot-controlled). Accept either "__order" or "order".
local explicit = m.__order or m.order
if type(explicit) == "table" then
for _, k in ipairs(explicit) do
push_key(k)
end
end
-- Soft preferred order (optional; safe even if headers change).
if type(preferred_order) == "table" then
for _, k in ipairs(preferred_order) do
push_key(k)
end
end
-- Remaining keys sorted, excluding order keys.
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_schema2(root_id, requirements_map, users_map)
-- Provide only "soft defaults" here; headers remain fully dynamic.
local req_groups = _map_to_groups(requirements_map, { "Skills", "Weapon Types", "Stances" })
local usr_groups = _map_to_groups(users_map, { "Classes", "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 _render_schema1(frame, payload, notes_wt)
local identity = _safe_tbl(payload.identity)
local display_name = _safe_str(identity.display_name, "Unknown Skill")
local idx = _to_int(GI.arg(frame, "index", "1"), 1)
if idx < 1 then idx = 1 end
local root_id = GI.arg(frame, "id", "")
if root_id == "" then
root_id = "sv-skill-" .. _slugify(display_name) .. "-" .. tostring(idx)
end
local default_level, _base_max, max_level = _normalize_level(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.top:node(_build_identity(frame, root_id, identity, notes_wt))
box.top:node(_build_meta_row(frame, payload.meta_row))
local reqrow = _build_req_users(root_id, payload.requirements, payload.users)
if reqrow then box.top:node(reqrow) end
local level_panel, actual_default = _build_level(root_id, default_level, max_level, 1, nil)
box.bottom:node(level_panel)
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_tabs(frame, root_id, payload.tabs, actual_default))
return tostring(box.root)
end
local function _render_schema2(frame, payload, notes_wt)
local identity = _safe_tbl(payload.identity)
local display_name = _safe_str(identity.display_name, "Unknown Skill")
local idx = _to_int(GI.arg(frame, "index", "1"), 1)
if idx < 1 then idx = 1 end
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_schema2(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")
-- Helpful attributes (optional / future-proof)
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))
box.top:node(_build_meta_row_schema2(frame, payload.skill_meta))
local reqrow = _build_req_users_schema2(root_id, payload.requirements, payload.users)
if reqrow then box.top:node(reqrow) end
local level_panel, actual_default = _build_level(root_id, default_level, max_level, min_level, _safe_tbl(payload.level).overcap)
box.bottom:node(level_panel)
box.bottom:node(_build_scaling_top_schema2(frame, payload.skill_scaling, actual_default))
box.bottom:node(_build_core_stats_schema2(payload.skill_scaling, actual_default))
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_schema2(frame, payload, notes_wt)
end
return _render_schema1(frame, payload, notes_wt)
end
return p