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

Join the Playtest on Steam Now: SpiritVale

Module:GameInfo/Skills: Difference between revisions

From SpiritVale Wiki
No edit summary
Tags: Mobile edit Mobile web edit
No edit summary
Tags: Mobile edit Mobile web edit
Line 43: Line 43:
end
end


-- ---------------------------------------------------------------------------
-- NOWIKI / STRIP MARKERS
-- MediaWiki may pass nowiki content as strip markers (UNIQ...). We must unstrip
-- before jsonDecode if users/bots wrap |data= in <nowiki>...</nowiki>.
-- ---------------------------------------------------------------------------
local function _clean_control_chars(s)
if type(s) ~= "string" then return s end
-- Remove ASCII control chars except tab/newline/carriage return.
return (s:gsub("[%z\1-\8\11\12\14-\31\127]", ""))
end
local function _unstrip_nowiki(s)
if type(s) ~= "string" then return s end
s = _clean_control_chars(s)
if mw.text and type(mw.text.unstripNoWiki) == "function" then
local ok, out = pcall(mw.text.unstripNoWiki, s)
if ok and type(out) == "string" then return out end
end
if mw.text and type(mw.text.unstrip) == "function" then
local ok, out = pcall(mw.text.unstrip, s)
if ok and type(out) == "string" then return out end
end
return s
end
-- ---------------------------------------------------------------------------
-- Definitions-safe rendering
-- We only expand the {{def}} template (never arbitrary templates).
-- We also normalize literal "\u007C" sequences (double-escaped pipes).
-- ---------------------------------------------------------------------------
local DEF_DOMAINS = {
Cast = true, Damage = true, Element = true, Aura = true,
Event = true, Stance = true, Stat = true, Target = true,
}
local function _normalize_pipe_tokens(s)
s = _trim(s)
if s == "" then return "" end
-- Convert literal backslash-u pipes into real pipes (handles "\\u007C" cases after jsonDecode).
s = s:gsub("\\u007C", "|")
-- Handle common HTML entities (just in case).
s = s:gsub("&#124;", "|"):gsub("&vert;", "|")
return s
end
local function _parse_def_wt(s)
-- Accept: {{def|Domain|Key|noicon=1}} (also tolerates whitespace)
s = _trim(s)
s = _normalize_pipe_tokens(s)
if not s:match("^%{%{%s*[Dd][Ee][Ff]%s*%|") then return nil end
if not s:match("%}%}%s*$") then return nil end
-- Strip outer braces
local inner = s:gsub("^%{%{%s*[Dd][Ee][Ff]%s*%|", "")
inner = inner:gsub("%}%}%s*$", "")
local parts = {}
for part in inner:gmatch("([^|]+)") do
part = _trim(part)
if part ~= "" then table.insert(parts, part) end
end
if #parts < 2 then return nil end
local args = { parts[1], parts[2] } -- domain, key
for i = 3, #parts do
local p = parts[i]
local eq = p:find("=", 1, true)
if eq then
local k = _trim(p:sub(1, eq - 1))
local v = _trim(p:sub(eq + 1))
if k ~= "" then args[k] = v end
else
table.insert(args, p)
end
end
return args
end
local function _render_def_or_text(frame, s)
s = _trim(s)
if s == "" then return nil end
s = _normalize_pipe_tokens(s)
-- 1) Template-shaped: {{def|...}}
local def_args = _parse_def_wt(s)
if def_args then
return frame:expandTemplate{ title = "def", args = def_args }
end
-- 2) Domain|Key token (only for known domains)
local bar = s:find("|", 1, true)
if bar then
local domain = _trim(s:sub(1, bar - 1))
local key    = _trim(s:sub(bar + 1))
if DEF_DOMAINS[domain] and key ~= "" then
return frame:expandTemplate{ title = "def", args = { domain, key } }
end
end
-- 3) Plain text, fully escaped.
return mw.text.nowiki(s)
end
-- ---------------------------------------------------------------------------
-- JSON decode + schema routing
-- ---------------------------------------------------------------------------
local function _decode_payload(frame)
local function _decode_payload(frame)
local raw = GI.arg(frame, "data", "")
local raw = GI.arg(frame, "data", "")
raw = _trim(raw)
raw = _trim(raw)
if raw == "" then return nil, "missing |data=", "" end
if raw == "" then return nil, "missing |data=", "" end
-- Restore nowiki-wrapped JSON if present.
raw = _unstrip_nowiki(raw)


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


-- ---------------------------------------------------------------------------
-- Schema 2 -> internal normalization (keeps existing DOM/layout code)
-- ---------------------------------------------------------------------------
local function _normalize_file_title(page)
local function _normalize_file_title(page)
page = _trim(page)
page = _trim(page)
Line 140: Line 261:
end
end


local function _meta_lines(lines)
local function _meta_lines(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")
local any = false
local any = false
for _, ln in ipairs(lines) do
for _, ln in ipairs(lines) do
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_or_text(frame, s) or mw.text.nowiki("—")
wrap:tag("span"):wikitext(wt)
any = true
any = true
end
end
end
end
if not any then
if not any then
wrap:tag("span"):wikitext("—")
wrap:tag("span"):wikitext("—")
end
end
return wrap
return wrap
end
local function _render_keyword_pill(frame, pill_text)
pill_text = _trim(pill_text)
if pill_text == "" then
return nil
end
local bar = pill_text:find("|", 1, true)
if bar then
local domain = _trim(pill_text:sub(1, bar - 1))
local key    = _trim(pill_text:sub(bar + 1))
if domain ~= "" and key ~= "" then
return frame:expandTemplate{ title = "def", args = { domain, key } }
end
end
return mw.text.nowiki(pill_text)
end
end


Line 222: Line 329:
end
end


local function _build_grouped_disclose(root_id, key, label, groups)
local function _build_grouped_disclose(frame, 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
Line 267: Line 374:
local li = ul:tag("li")
local li = ul:tag("li")


local label_node = _meta_lines(it.label_lines)
local label_node = _meta_lines(frame, it.label_lines)


local link = _safe_tbl(it.link)
local link = _safe_tbl(it.link)
Line 282: Line 389:
end
end


local function _build_req_users(root_id, requirements, users)
local function _build_req_users(frame, root_id, requirements, users)
requirements = _safe_tbl(requirements)
requirements = _safe_tbl(requirements)
users = _safe_tbl(users)
users = _safe_tbl(users)


local req_box, req_n = _build_grouped_disclose(root_id, "req", "Requirements", _safe_tbl(requirements.groups))
local req_box, req_n = _build_grouped_disclose(frame, root_id, "req", "Requirements", _safe_tbl(requirements.groups))
local usr_box, usr_n = _build_grouped_disclose(root_id, "usr", "Users", _safe_tbl(users.groups))
local usr_box, usr_n = _build_grouped_disclose(frame, root_id, "usr", "Users", _safe_tbl(users.groups))


if req_n == 0 and usr_n == 0 then return nil end
if req_n == 0 and usr_n == 0 then return nil end
Line 297: Line 404:
end
end


local function _build_identity(root_id, identity, notes_wt)
local function _build_identity(frame, root_id, identity, notes_wt)
identity = _safe_tbl(identity)
identity = _safe_tbl(identity)
local root = mw.html.create("div"):addClass("sv-skill-head")
local root = mw.html.create("div"):addClass("sv-skill-head")
Line 329: Line 436:
end
end


local function _build_meta_row(meta_row)
local function _build_meta_row(frame, meta_row)
meta_row = _safe_tbl(meta_row)
meta_row = _safe_tbl(meta_row)


Line 349: Line 456:


local wrap = card:tag("div"):addClass("sv-meta-textwrap")
local wrap = card:tag("div"):addClass("sv-meta-textwrap")
wrap:tag("div"):addClass("sv-meta-text"):node(_meta_lines(cell.label_lines))
wrap:tag("div"):addClass("sv-meta-text"):node(_meta_lines(frame, cell.label_lines))
end
end


Line 355: Line 462:
end
end


-- Level normalization:
-- Level normalization (schema 1): base_max + max
-- - max      = absolute cap (incl. gear) if provided, else base_max
-- - base_max = natural cap (no gear) if provided, else max
-- - default  = base_max (slider starts at natural cap)
local function _normalize_level(level_obj)
local function _normalize_level(level_obj)
level_obj = _safe_tbl(level_obj)
level_obj = _safe_tbl(level_obj)
Line 432: Line 536:
col:tag("div"):addClass("sv-scaling-label"):wikitext("Damage")
col:tag("div"):addClass("sv-scaling-label"):wikitext("Damage")
end
end
-- NOTE: scaling_top.modifier intentionally not rendered (handled elsewhere).


do
do
Line 544: Line 646:
local pills = panel:tag("div"):addClass("sv-tab-pills")
local pills = panel:tag("div"):addClass("sv-tab-pills")
for _, kw in ipairs(_safe_tbl(keywords.pills)) do
for _, kw in ipairs(_safe_tbl(keywords.pills)) do
local wt = _render_keyword_pill(frame, kw)
local wt = _render_def_or_text(frame, kw)
if wt then
if wt then
pills:tag("span"):addClass("sv-pill"):wikitext(wt)
pills:tag("span"):addClass("sv-pill"):wikitext(wt)
Line 621: Line 723:


return root
return root
end
-- Schema 2 adapter: convert to schema-1-like structure for rendering.
local function _normalize_schema2(payload)
local out = {}
out.schema = 2
out.identity = _safe_tbl(payload.identity)
-- skill_meta -> meta_row (4)
out.meta_row = {}
local sm = _safe_tbl(payload.skill_meta)
for i = 1, 4 do
local cell = _safe_tbl(sm[i])
local icon_page = _safe_str(cell.icon, "")
local label_wt  = _safe_str(cell.label_wt, _safe_str(cell.label, ""))
out.meta_row[i] = {
icon = { page = icon_page, alt = "" },
label_lines = label_wt ~= "" and { label_wt } or {},
}
end
-- requirements/users: map -> groups[]
local function map_to_groups(map_tbl, preferred_order)
local m = _safe_tbl(map_tbl)
local used = {}
local groups = {}
-- preferred keys first
for _, k in ipairs(preferred_order or {}) do
if m[k] ~= nil then
used[k] = true
table.insert(groups, { title = k, items = m[k] })
end
end
-- remaining keys sorted
local keys = {}
for k, _ in pairs(m) do
if not used[k] then table.insert(keys, tostring(k)) end
end
table.sort(keys)
for _, k in ipairs(keys) do
table.insert(groups, { title = k, items = m[k] })
end
-- convert items to {label_lines=[...]}
local out_groups = {}
for _, g in ipairs(groups) do
local title = _safe_str(g.title, "")
local items = _safe_tbl(g.items)
local out_items = {}
for _, it in ipairs(items) do
if type(it) == "string" then
table.insert(out_items, { label_lines = { it } })
elseif type(it) == "table" then
local lines = {}
for _, ln in ipairs(it) do table.insert(lines, tostring(ln)) end
table.insert(out_items, { label_lines = lines })
end
end
table.insert(out_groups, { title = title, items = out_items })
end
return { groups = out_groups }
end
out.requirements = map_to_groups(payload.requirements, { "Skills", "Weapon Types", "Stances" })
out.users        = map_to_groups(payload.users,        { "Classes", "Summons" })
-- level: base/default/overcap.max -> base_max/max for renderer
do
local lv = _safe_tbl(payload.level)
local base = _to_int(lv.base, 1)
local natural = _to_int(lv.default, base)
local overcap = _safe_tbl(lv.overcap)
local max = _to_int(overcap.max, natural)
out.level = {
base_max = natural,
max = max,
}
end
-- skill_scaling -> scaling_top + core_stats
do
local sc = _safe_tbl(payload.skill_scaling)
-- scaling_top.damage
out.scaling_top = {
damage = _safe_tbl(_safe_tbl(sc.damage).value),
scaling_lines = {},
}
-- stat_scaling -> a single friendly line for now
local ss = _safe_tbl(sc.stat_scaling)
if #ss > 0 then
local parts = {}
for _, it in ipairs(ss) do
it = _safe_tbl(it)
local stat = _safe_str(it.stat_wt, "")
local val  = _safe_str(it.value, "")
if stat ~= "" and val ~= "" then
-- Keep as text; later we can render stat_wt as defs/icons.
table.insert(parts, stat .. ": " .. val)
end
end
if #parts > 0 then
table.insert(out.scaling_top.scaling_lines, { text = table.concat(parts, " • ") })
end
end
-- core_stats from named fields
local function core_cell(key, unit, vobj)
return { key = key, unit = unit, value = vobj }
end
out.core_stats = {
core_cell("Cost",      _safe_str(_safe_tbl(sc.cost).unit, ""),      _safe_tbl(_safe_tbl(sc.cost).value)),
core_cell("Cast Time", _safe_str(_safe_tbl(sc.cast_time).unit, ""), _safe_tbl(_safe_tbl(sc.cast_time).value)),
core_cell("Cooldown",  _safe_str(_safe_tbl(sc.cooldown).unit, ""),  _safe_tbl(_safe_tbl(sc.cooldown).value)),
core_cell("Range",    _safe_str(_safe_tbl(sc.range).unit, ""),    _safe_tbl(_safe_tbl(sc.range).value)),
core_cell("Area",      _safe_str(_safe_tbl(sc.area).unit, ""),      _safe_tbl(_safe_tbl(sc.area).value)),
core_cell("Duration",  _safe_str(_safe_tbl(sc.duration).unit, ""),  _safe_tbl(_safe_tbl(sc.duration).value)),
}
end
-- tabs: mostly compatible; add an adapter for events card subtype
out.tabs = _safe_tbl(payload.tabs)
-- events cards: if schema2 uses stats.cause, map to card.type for renderer
do
local t = _safe_tbl(out.tabs)
local ev = _safe_tbl(t.events)
local cards = _safe_tbl(ev.cards)
for _, c in ipairs(cards) do
c = _safe_tbl(c)
if c.type == nil then
local stats = _safe_tbl(c.stats)
if stats.cause ~= nil then
c.type = stats.cause
end
end
end
end
return out
end
end


Line 632: Line 885:
if not payload then
if not payload then
return _err(err, debug and preview or nil)
return _err(err, debug and preview or nil)
end
-- Schema 2 normalization (schema 1 passes through unchanged)
if payload.schema == 2 then
payload = _normalize_schema2(payload)
end
end


Line 659: Line 917:
box.bottom:addClass("sv-skill-bottom")
box.bottom:addClass("sv-skill-bottom")


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


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



Revision as of 02:53, 26 February 2026

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 contain schema = 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. Default 1.
  • notes — Wikitext content shown in the Notes popup.
  • debug — If set to 1, 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)
  • requirements and users (optional grouped lists)
  • scaling_top, core_stats
  • tabs (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 as data-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-toggle and sv-hidden
  • Tabs use data-tabs and data-tabs-root
  • The custom level slider uses data-sv-slider and ARIA slider attributes

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

-- ---------------------------------------------------------------------------
-- NOWIKI / STRIP MARKERS
-- MediaWiki may pass nowiki content as strip markers (UNIQ...). We must unstrip
-- before jsonDecode if users/bots wrap |data= in <nowiki>...</nowiki>.
-- ---------------------------------------------------------------------------
local function _clean_control_chars(s)
	if type(s) ~= "string" then return s end
	-- Remove ASCII control chars except tab/newline/carriage return.
	return (s:gsub("[%z\1-\8\11\12\14-\31\127]", ""))
end

local function _unstrip_nowiki(s)
	if type(s) ~= "string" then return s end
	s = _clean_control_chars(s)

	if mw.text and type(mw.text.unstripNoWiki) == "function" then
		local ok, out = pcall(mw.text.unstripNoWiki, s)
		if ok and type(out) == "string" then return out end
	end
	if mw.text and type(mw.text.unstrip) == "function" then
		local ok, out = pcall(mw.text.unstrip, s)
		if ok and type(out) == "string" then return out end
	end

	return s
end

-- ---------------------------------------------------------------------------
-- Definitions-safe rendering
-- We only expand the {{def}} template (never arbitrary templates).
-- We also normalize literal "\u007C" sequences (double-escaped pipes).
-- ---------------------------------------------------------------------------
local DEF_DOMAINS = {
	Cast = true, Damage = true, Element = true, Aura = true,
	Event = true, Stance = true, Stat = true, Target = true,
}

local function _normalize_pipe_tokens(s)
	s = _trim(s)
	if s == "" then return "" end
	-- Convert literal backslash-u pipes into real pipes (handles "\\u007C" cases after jsonDecode).
	s = s:gsub("\\u007C", "|")
	-- Handle common HTML entities (just in case).
	s = s:gsub("&#124;", "|"):gsub("&vert;", "|")
	return s
end

local function _parse_def_wt(s)
	-- Accept: {{def|Domain|Key|noicon=1}} (also tolerates whitespace)
	s = _trim(s)
	s = _normalize_pipe_tokens(s)

	if not s:match("^%{%{%s*[Dd][Ee][Ff]%s*%|") then return nil end
	if not s:match("%}%}%s*$") then return nil end

	-- Strip outer braces
	local inner = s:gsub("^%{%{%s*[Dd][Ee][Ff]%s*%|", "")
	inner = inner:gsub("%}%}%s*$", "")

	local parts = {}
	for part in inner:gmatch("([^|]+)") do
		part = _trim(part)
		if part ~= "" then table.insert(parts, part) end
	end
	if #parts < 2 then return nil end

	local args = { parts[1], parts[2] } -- domain, key

	for i = 3, #parts do
		local p = parts[i]
		local eq = p:find("=", 1, true)
		if eq then
			local k = _trim(p:sub(1, eq - 1))
			local v = _trim(p:sub(eq + 1))
			if k ~= "" then args[k] = v end
		else
			table.insert(args, p)
		end
	end

	return args
end

local function _render_def_or_text(frame, s)
	s = _trim(s)
	if s == "" then return nil end

	s = _normalize_pipe_tokens(s)

	-- 1) Template-shaped: {{def|...}}
	local def_args = _parse_def_wt(s)
	if def_args then
		return frame:expandTemplate{ title = "def", args = def_args }
	end

	-- 2) Domain|Key token (only for known domains)
	local bar = s:find("|", 1, true)
	if bar then
		local domain = _trim(s:sub(1, bar - 1))
		local key    = _trim(s:sub(bar + 1))
		if DEF_DOMAINS[domain] and key ~= "" then
			return frame:expandTemplate{ title = "def", args = { domain, key } }
		end
	end

	-- 3) Plain text, fully escaped.
	return mw.text.nowiki(s)
end

-- ---------------------------------------------------------------------------
-- JSON decode + schema routing
-- ---------------------------------------------------------------------------
local function _decode_payload(frame)
	local raw = GI.arg(frame, "data", "")
	raw = _trim(raw)
	if raw == "" then return nil, "missing |data=", "" end

	-- Restore nowiki-wrapped JSON if present.
	raw = _unstrip_nowiki(raw)

	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

-- ---------------------------------------------------------------------------
-- Schema 2 -> internal normalization (keeps existing DOM/layout code)
-- ---------------------------------------------------------------------------
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

local function _meta_lines(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
			local wt = _render_def_or_text(frame, s) or mw.text.nowiki("—")
			wrap:tag("span"):wikitext(wt)
			any = true
		end
	end

	if not any then
		wrap:tag("span"):wikitext("—")
	end

	return wrap
end

local function _build_notes_tip(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")
		: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("id", tip_id)
		:attr("role", "dialog")
		:attr("aria-label", "Notes")

	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-hint"):wikitext("Click to close")

	pop:tag("div"):addClass("sv-tip-pop-body"):wikitext(notes_wt)

	return wrap
end

local function _count_group_items(groups)
	local n = 0
	for _, g in ipairs(_safe_tbl(groups)) do
		for _, _ in ipairs(_safe_tbl(g.items)) do n = n + 1 end
	end
	return n
end

local function _build_grouped_disclose(frame, 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")
		: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("id", pop_id)
		:attr("role", "dialog")
		: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))
	head:tag("div"):addClass("sv-disclose-pop-hint"):wikitext("Click to close")

	local ul = pop:tag("ul"):addClass("sv-disclose-list")

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

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

		for _, it in ipairs(items) do
			it = _safe_tbl(it)
			local li = ul:tag("li")

			local label_node = _meta_lines(frame, it.label_lines)

			local link = _safe_tbl(it.link)
			local page = _safe_str(link.page, "")
			if page ~= "" then
				li:wikitext(string.format("[[%s|%s]]", page, tostring(label_node)))
			else
				li:node(label_node)
			end
		end
	end

	return wrap, total
end

local function _build_req_users(frame, root_id, requirements, users)
	requirements = _safe_tbl(requirements)
	users = _safe_tbl(users)

	local req_box, req_n = _build_grouped_disclose(frame, root_id, "req", "Requirements", _safe_tbl(requirements.groups))
	local usr_box, usr_n = _build_grouped_disclose(frame, 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(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

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")
		wrap:tag("div"):addClass("sv-meta-text"):node(_meta_lines(frame, cell.label_lines))
	end

	return meta
end

-- Level normalization (schema 1): base_max + max
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

local function _build_level(default_level, max_level)
	local default = _to_int(default_level, 1)
	local max = _to_int(max_level, 1)

	if max < 1 then max = 1 end
	if default < 1 then default = 1 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))

	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", "1")
		:attr("aria-valuemax", tostring(max))
		:attr("aria-valuenow", tostring(default))
		:attr("data-min", "1")
		: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
end

local function _build_scaling_top(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 _, 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

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

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_def_or_text(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 adapter: convert to schema-1-like structure for rendering.
local function _normalize_schema2(payload)
	local out = {}

	out.schema = 2
	out.identity = _safe_tbl(payload.identity)

	-- skill_meta -> meta_row (4)
	out.meta_row = {}
	local sm = _safe_tbl(payload.skill_meta)
	for i = 1, 4 do
		local cell = _safe_tbl(sm[i])
		local icon_page = _safe_str(cell.icon, "")
		local label_wt  = _safe_str(cell.label_wt, _safe_str(cell.label, ""))

		out.meta_row[i] = {
			icon = { page = icon_page, alt = "" },
			label_lines = label_wt ~= "" and { label_wt } or {},
		}
	end

	-- requirements/users: map -> groups[]
	local function map_to_groups(map_tbl, preferred_order)
		local m = _safe_tbl(map_tbl)
		local used = {}
		local groups = {}

		-- preferred keys first
		for _, k in ipairs(preferred_order or {}) do
			if m[k] ~= nil then
				used[k] = true
				table.insert(groups, { title = k, items = m[k] })
			end
		end

		-- remaining keys sorted
		local keys = {}
		for k, _ in pairs(m) do
			if not used[k] then table.insert(keys, tostring(k)) end
		end
		table.sort(keys)

		for _, k in ipairs(keys) do
			table.insert(groups, { title = k, items = m[k] })
		end

		-- convert items to {label_lines=[...]}
		local out_groups = {}
		for _, g in ipairs(groups) do
			local title = _safe_str(g.title, "")
			local items = _safe_tbl(g.items)
			local out_items = {}

			for _, it in ipairs(items) do
				if type(it) == "string" then
					table.insert(out_items, { label_lines = { it } })
				elseif type(it) == "table" then
					local lines = {}
					for _, ln in ipairs(it) do table.insert(lines, tostring(ln)) end
					table.insert(out_items, { label_lines = lines })
				end
			end

			table.insert(out_groups, { title = title, items = out_items })
		end

		return { groups = out_groups }
	end

	out.requirements = map_to_groups(payload.requirements, { "Skills", "Weapon Types", "Stances" })
	out.users        = map_to_groups(payload.users,        { "Classes", "Summons" })

	-- level: base/default/overcap.max -> base_max/max for renderer
	do
		local lv = _safe_tbl(payload.level)
		local base = _to_int(lv.base, 1)
		local natural = _to_int(lv.default, base)
		local overcap = _safe_tbl(lv.overcap)
		local max = _to_int(overcap.max, natural)

		out.level = {
			base_max = natural,
			max = max,
		}
	end

	-- skill_scaling -> scaling_top + core_stats
	do
		local sc = _safe_tbl(payload.skill_scaling)

		-- scaling_top.damage
		out.scaling_top = {
			damage = _safe_tbl(_safe_tbl(sc.damage).value),
			scaling_lines = {},
		}

		-- stat_scaling -> a single friendly line for now
		local ss = _safe_tbl(sc.stat_scaling)
		if #ss > 0 then
			local parts = {}
			for _, it in ipairs(ss) do
				it = _safe_tbl(it)
				local stat = _safe_str(it.stat_wt, "")
				local val  = _safe_str(it.value, "")
				if stat ~= "" and val ~= "" then
					-- Keep as text; later we can render stat_wt as defs/icons.
					table.insert(parts, stat .. ": " .. val)
				end
			end
			if #parts > 0 then
				table.insert(out.scaling_top.scaling_lines, { text = table.concat(parts, " • ") })
			end
		end

		-- core_stats from named fields
		local function core_cell(key, unit, vobj)
			return { key = key, unit = unit, value = vobj }
		end

		out.core_stats = {
			core_cell("Cost",      _safe_str(_safe_tbl(sc.cost).unit, ""),      _safe_tbl(_safe_tbl(sc.cost).value)),
			core_cell("Cast Time", _safe_str(_safe_tbl(sc.cast_time).unit, ""), _safe_tbl(_safe_tbl(sc.cast_time).value)),
			core_cell("Cooldown",  _safe_str(_safe_tbl(sc.cooldown).unit, ""),  _safe_tbl(_safe_tbl(sc.cooldown).value)),
			core_cell("Range",     _safe_str(_safe_tbl(sc.range).unit, ""),     _safe_tbl(_safe_tbl(sc.range).value)),
			core_cell("Area",      _safe_str(_safe_tbl(sc.area).unit, ""),      _safe_tbl(_safe_tbl(sc.area).value)),
			core_cell("Duration",  _safe_str(_safe_tbl(sc.duration).unit, ""),  _safe_tbl(_safe_tbl(sc.duration).value)),
		}
	end

	-- tabs: mostly compatible; add an adapter for events card subtype
	out.tabs = _safe_tbl(payload.tabs)

	-- events cards: if schema2 uses stats.cause, map to card.type for renderer
	do
		local t = _safe_tbl(out.tabs)
		local ev = _safe_tbl(t.events)
		local cards = _safe_tbl(ev.cards)
		for _, c in ipairs(cards) do
			c = _safe_tbl(c)
			if c.type == nil then
				local stats = _safe_tbl(c.stats)
				if stats.cause ~= nil then
					c.type = stats.cause
				end
			end
		end
	end

	return out
end

function p.render(frame)
	frame = frame or mw.getCurrentFrame()

	local debug = (GI.arg(frame, "debug", "0") == "1")
	local notes_wt = GI.arg(frame, "notes", "")

	local payload, err, preview = _decode_payload(frame)
	if not payload then
		return _err(err, debug and preview or nil)
	end

	-- Schema 2 normalization (schema 1 passes through unchanged)
	if payload.schema == 2 then
		payload = _normalize_schema2(payload)
	end

	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

	-- Slider default = natural cap (base_max if present; else max)
	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(frame, root_id, payload.requirements, payload.users)
	if reqrow then box.top:node(reqrow) end

	local level_panel, actual_default = _build_level(default_level, max_level)

	box.bottom:node(level_panel)
	box.bottom:node(_build_scaling_top(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

return p