Module:GameInfo/Skills: Difference between revisions
More actions
No edit summary |
No edit summary |
||
| Line 1: | Line 1: | ||
-- Module:GameInfo/Skills | -- Module:GameInfo/Skills | ||
-- Phase 4.1: Skills renderer ( | -- Phase 4.1: Skills renderer (TemplateStyles-safe markup; no <details>/<button>/<a>). | ||
local p = {} | local p = {} | ||
| Line 15: | Line 7: | ||
local _file_exists_cache = {} | local _file_exists_cache = {} | ||
local function _trim(s) | local function _trim(s) | ||
| Line 56: | Line 44: | ||
local function _get_arg(frame, key) | local function _get_arg(frame, key) | ||
local args = frame and frame.args or {} | |||
local args = | |||
local v = _trim(args[key]) | local v = _trim(args[key]) | ||
if v ~= "" then return v end | if v ~= "" then return v end | ||
local parent = | local parent = frame and frame.getParent and frame:getParent() or nil | ||
local pargs = | local pargs = parent and parent.args or {} | ||
v = _trim(pargs[key]) | v = _trim(pargs[key]) | ||
if v ~= "" then return v end | if v ~= "" then return v end | ||
| Line 69: | Line 56: | ||
end | end | ||
local function | local function _strip_wrappers(raw) | ||
raw = raw == nil and "" or tostring(raw) | raw = raw == nil and "" or tostring(raw) | ||
-- | -- Undo strip markers created by <nowiki>/<pre> etc. | ||
if mw.text | if mw.text and mw.text.unstripNoWiki then | ||
raw = mw.text.unstripNoWiki(raw) | |||
end | end | ||
if mw.text and mw.text.unstrip then | |||
raw = mw.text.unstrip(raw) | |||
raw = | |||
end | end | ||
raw = _trim(raw) | raw = _trim(raw) | ||
-- | -- In case literal wrapper tags survive in some paths: | ||
raw = raw:gsub("^<nowiki>%s*", ""):gsub("%s*</nowiki>%s*$", "") | raw = raw:gsub("^<nowiki>%s*", ""):gsub("%s*</nowiki>%s*$", "") | ||
raw = raw:gsub("^<pre>%s*", ""):gsub("%s*</pre>%s*$", "") | raw = raw:gsub("^<pre>%s*", ""):gsub("%s*</pre>%s*$", "") | ||
| Line 108: | Line 77: | ||
end | end | ||
local function _decode_json( | local function _decode_json(raw) | ||
raw = _strip_wrappers( | raw = _strip_wrappers(raw) | ||
if raw == "" then return nil, "missing data" | 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" | ||
end | end | ||
if type(decoded) ~= "table" then | if type(decoded) ~= "table" then | ||
return nil, "json is not object" | return nil, "json is not object" | ||
end | end | ||
return decoded, nil | return decoded, nil | ||
end | end | ||
| Line 133: | Line 98: | ||
page = _trim(page) | page = _trim(page) | ||
if page == "" then return nil end | if page == "" then return nil end | ||
if page:match("^[Ff]ile:") then | |||
-- Accept File: / Image: / bare names | |||
if page:match("^[Ff]ile:") or page:match("^[Ii]mage:") then | |||
-- Normalize to File: | |||
page = page:gsub("^[Ii]mage:", "File:"):gsub("^[Ff]ile:", "File:") | |||
return page | |||
end | end | ||
return "File:" .. page | return "File:" .. page | ||
end | end | ||
| Line 143: | Line 113: | ||
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 | if t then | ||
-- Some installs are quirky with File: pages; try both .exists and .file. | |||
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 165: | Line 148: | ||
return mw.html.create("span") | return mw.html.create("span") | ||
:addClass("sv-img") | :addClass("sv-img") | ||
:wikitext(string.format("[[%s|%s|link=|alt=%s]]", | :wikitext(string.format("[[%s|%s|link=|alt=%s]]", title, size, mw.text.nowiki(_safe_str(alt, "")))) | ||
end | end | ||
-- ----------------------------------------------------------------------------- | -- ----------------------------------------------------------------------------- | ||
-- VALUE | -- VALUE HELPERS | ||
-- ----------------------------------------------------------------------------- | -- ----------------------------------------------------------------------------- | ||
-- | -- Values are display-only: | ||
-- - { text = "..." } = constant | -- - { text = "..." } = constant | ||
-- - { series = [...] } = per-level; Lua emits data-series for JS to swap | -- - { series = [...] } = per-level; Lua emits data-series for JS to swap | ||
| Line 185: | Line 164: | ||
end | end | ||
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 212: | Line 190: | ||
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) | ||
| Line 220: | Line 197: | ||
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 | end | ||
-- ----------------------------------------------------------------------------- | -- ----------------------------------------------------------------------------- | ||
-- | -- NOTES / DISCLOSE (NO <details>/<summary>) | ||
-- ----------------------------------------------------------------------------- | -- ----------------------------------------------------------------------------- | ||
local function _build_notes_tip(notes_wt) | local function _build_notes_tip(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 | local tip_id = root_id .. "-notes" | ||
local | |||
local wrap = mw.html.create("div"):addClass("sv-tip") | |||
local btn = wrap:tag("span") | |||
:addClass("sv-tip-btn") | :addClass("sv-tip-btn") | ||
:attr("role", "button") | :attr("role", "button") | ||
:attr("tabindex", "0") | :attr("tabindex", "0") | ||
:attr("data-sv-toggle", "1") | |||
:attr("aria-label", "Notes") | :attr("aria-label", "Notes") | ||
:attr("aria-controls", tip_id) | |||
:attr("aria-expanded", "false") | :attr("aria-expanded", "false") | ||
btn:tag("span") | |||
:addClass("sv-ico") | :addClass("sv-ico") | ||
:addClass("sv-ico--info") | :addClass("sv-ico--info") | ||
| Line 251: | Line 230: | ||
:wikitext("i") | :wikitext("i") | ||
local pop = | local pop = wrap:tag("div") | ||
:addClass("sv-tip-pop") | :addClass("sv-tip-pop") | ||
:addClass("sv-hidden") | |||
:attr("id", tip_id) | |||
:attr("role", "dialog") | :attr("role", "dialog") | ||
:attr("aria-label", "Notes") | :attr("aria-label", "Notes") | ||
| Line 261: | Line 242: | ||
pop:tag("div"):addClass("sv-tip-pop-body"):wikitext(notes_wt) | pop:tag("div"):addClass("sv-tip-pop-body"):wikitext(notes_wt) | ||
return wrap | |||
return | |||
end | end | ||
| Line 333: | Line 254: | ||
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 | local pop_id = root_id .. "-" .. key | ||
local | |||
local wrap = mw.html.create("div"):addClass("sv-disclose") | |||
local btn = wrap:tag("span") | |||
:addClass("sv-disclose-btn") | |||
:attr("role", "button") | :attr("role", "button") | ||
:attr("tabindex", "0") | :attr("tabindex", "0") | ||
:attr("data-sv-toggle", "1") | |||
:attr("aria-label", label) | :attr("aria-label", label) | ||
:attr("aria-controls", pop_id) | |||
:attr("aria-expanded", "false") | :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 = | local pop = wrap:tag("div") | ||
:addClass("sv-disclose-pop") | :addClass("sv-disclose-pop") | ||
:addClass("sv-hidden") | |||
:attr("id", pop_id) | |||
:attr("role", "dialog") | :attr("role", "dialog") | ||
:attr("aria-label", label) | :attr("aria-label", label) | ||
| Line 363: | Line 292: | ||
if title ~= "" and #items > 0 then | if title ~= "" and #items > 0 then | ||
ul:tag("li") | ul:tag("li"):addClass("sv-disclose-group-title"):wikitext(mw.text.nowiki(title)) | ||
end | end | ||
for _, it in ipairs(items) do | for _, it in ipairs(items) do | ||
it = _safe_tbl(it) | it = _safe_tbl(it) | ||
local li = ul:tag("li") | |||
local label_node = _meta_lines(it.label_lines) | local label_node = _meta_lines(it.label_lines) | ||
local link = _safe_tbl(it.link) | local link = _safe_tbl(it.link) | ||
local page = _safe_str(link.page, "") | local page = _safe_str(link.page, "") | ||
if page ~= "" then | if page ~= "" then | ||
-- | -- Wikitext link (safe); label_node is HTML span that is allowed. | ||
li:wikitext(string.format("[[%s|%s]]", page, tostring(label_node))) | li:wikitext(string.format("[[%s|%s]]", page, tostring(label_node))) | ||
else | else | ||
| Line 386: | Line 312: | ||
end | end | ||
return | return wrap, total | ||
end | end | ||
local function _build_req_users(requirements, users) | local function _build_req_users(root_id, requirements, users) | ||
requirements = _safe_tbl(requirements) | requirements = _safe_tbl(requirements) | ||
users = _safe_tbl(users) | users = _safe_tbl(users) | ||
local | local req_box, req_n = _build_grouped_disclose(root_id, "req", "Requirements", _safe_tbl(requirements.groups)) | ||
local | 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 | if req_n == 0 and usr_n == 0 then | ||
| Line 401: | Line 327: | ||
local row = mw.html.create("div"):addClass("sv-reqrow") | local row = mw.html.create("div"):addClass("sv-reqrow") | ||
if | if req_box then row:node(req_box) end | ||
if | if usr_box then row:node(usr_box) end | ||
return row | return row | ||
end | end | ||
-- ----------------------------------------------------------------------------- | -- ----------------------------------------------------------------------------- | ||
-- | -- BUILDERS | ||
-- ----------------------------------------------------------------------------- | -- ----------------------------------------------------------------------------- | ||
local function _build_identity(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(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(cell.label_lines)) | |||
end | |||
return meta | |||
end | |||
local function _build_level(level_obj) | local function _build_level(level_obj) | ||
| Line 422: | Line 408: | ||
local root = mw.html.create("div"):addClass("sv-skill-level") | local root = mw.html.create("div"):addClass("sv-skill-level") | ||
local ui = root:tag("div"):addClass("sv-level-ui | local ui = root:tag("div"):addClass("sv-level-ui") | ||
ui:tag("div") | ui:tag("div") | ||
:addClass("sv-level-label") | :addClass("sv-level-label") | ||
| Line 438: | Line 424: | ||
:attr("step", "1") | :attr("step", "1") | ||
:attr("aria-label", "Skill level select") | :attr("aria-label", "Skill level select") | ||
slider:tag("div"):addClass("sv-level-ticklabels"):attr("aria-hidden", "true") | slider:tag("div"):addClass("sv-level-ticklabels"):attr("aria-hidden", "true") | ||
return root, default, max | return root, default, max | ||
end | end | ||
| Line 452: | Line 438: | ||
do | do | ||
local col = grid:tag("div"):addClass("sv-scaling-col | 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 459: | Line 445: | ||
do | do | ||
local col = grid:tag("div"):addClass("sv-scaling-col | 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.modifier, level) | _apply_value(v, scaling.modifier, level) | ||
| Line 506: | Line 492: | ||
return root | return root | ||
end | end | ||
| Line 547: | Line 516: | ||
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 552: | Line 522: | ||
:attr("data-tabs-root", root_id) | :attr("data-tabs-root", root_id) | ||
local list = tabs:tag("div"):addClass("sv-tabs-list | local list = tabs:tag("div"):addClass("sv-tabs-list") | ||
local panels = tabs:tag("div"):addClass("sv-tabs-panels") | local panels = tabs:tag("div"):addClass("sv-tabs-panels") | ||
local | local active_key = "mechanics" | ||
for | for _, spec in ipairs(tab_specs) do | ||
local | local t = list:tag("span") | ||
:addClass("sv-tab") | :addClass("sv-tab") | ||
:attr(" | :attr("role", "button") | ||
:attr(" | :attr("tabindex", spec.key == active_key and "0" or "-1") | ||
:attr(" | :attr("data-tab", spec.key) | ||
:attr("aria- | :attr("aria-selected", spec.key == active_key and "true" or "false") | ||
if | if spec.key == active_key then | ||
t:addClass("sv-tab--active") | |||
end | end | ||
t:wikitext(mw.text.nowiki(spec.title)) | |||
end | end | ||
-- Panel | -- Panel: Mechanics | ||
do | do | ||
local panel = panels:tag("div") | local panel = panels:tag("div") | ||
:addClass("sv-tabpanel") | :addClass("sv-tabpanel") | ||
:attr("data-panel", "mechanics") | |||
:attr("data- | |||
local gridwrap = panel | local gridwrap = panel:tag("div"):addClass("sv-kw-grid") | ||
for _, item in ipairs(_safe_tbl(mechanics.grid)) do | for _, item in ipairs(_safe_tbl(mechanics.grid)) do | ||
item = _safe_tbl(item) | item = _safe_tbl(item) | ||
| Line 596: | Line 558: | ||
end | end | ||
-- Panel | -- Panel: Keywords | ||
do | do | ||
local panel = panels:tag("div") | local panel = panels:tag("div") | ||
:addClass("sv-tabpanel") | :addClass("sv-tabpanel") | ||
: | :addClass("sv-hidden") | ||
:attr("data-panel", "keywords") | |||
:attr("data- | |||
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 | local s = _safe_str(kw, "") | ||
if | if s ~= "" then | ||
pills:tag(" | local pill = pills:tag("span"):addClass("sv-pill") | ||
-- If caller supplies "Domain|Key", we can call {{def|Domain|Key}}. | |||
if s:find("|", 1, true) then | |||
pill:wikitext("{{def|" .. s .. "}}") | |||
else | |||
pill:wikitext(mw.text.nowiki(s)) | |||
end | |||
end | end | ||
end | end | ||
| Line 627: | Line 591: | ||
end | end | ||
local function | local function render_ref_card(parent, card, is_event) | ||
card = _safe_tbl(card) | card = _safe_tbl(card) | ||
| Line 634: | Line 598: | ||
local icon = _safe_tbl(card.icon) | local icon = _safe_tbl(card.icon) | ||
local | local container = parent:tag("div") | ||
:addClass("sv-ref-card") | |||
local ico = container:tag("div"):addClass("sv-ref-ico") | local ico = container:tag("div"):addClass("sv-ref-ico") | ||
| Line 654: | Line 606: | ||
local text = container:tag("div"):addClass("sv-ref-text") | local text = container:tag("div"):addClass("sv-ref-text") | ||
-- Title (wikitext link; no raw <a>) | |||
local title_div = text:tag("div"):addClass("sv-ref-title") | |||
if page ~= "" then | |||
local | title_div:wikitext(string.format("[[%s|%s]]", page, mw.text.nowiki(title))) | ||
_apply_value( | 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 | return | ||
end | end | ||
-- Optional stats line | |||
local stats = card.stats | local stats = card.stats | ||
if type(stats) == "table" and next(stats) ~= nil then | if type(stats) == "table" and next(stats) ~= nil then | ||
local srow = text:tag("div"):addClass("sv-ref-stats") | local srow = text:tag("div"):addClass("sv-ref-stats") | ||
local d = srow:tag("span"):addClass("sv-ref-stat") | 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 c = srow:tag("span"):addClass("sv-ref-stat") | local st = srow:tag("span"):addClass("sv-ref-stat"); _apply_value(st, stats.stacks, level) | ||
local st = srow:tag("span"):addClass("sv-ref-stat") | |||
end | end | ||
end | end | ||
-- Panel | -- Panel: Effects | ||
do | do | ||
local panel = panels:tag("div") | local panel = panels:tag("div") | ||
:addClass("sv-tabpanel") | :addClass("sv-tabpanel") | ||
: | :addClass("sv-hidden") | ||
:attr("data-panel", "effects") | |||
:attr("data- | |||
local grid = panel:tag("div"):addClass("sv-ref-grid") | local grid = panel:tag("div"):addClass("sv-ref-grid") | ||
for _, card in ipairs(effects_cards) do | for _, card in ipairs(effects_cards) do | ||
render_ref_card(grid, card, false) | |||
end | end | ||
end | end | ||
-- Panel | -- Panel: Events | ||
do | do | ||
local panel = panels:tag("div") | local panel = panels:tag("div") | ||
:addClass("sv-tabpanel") | :addClass("sv-tabpanel") | ||
: | :addClass("sv-hidden") | ||
:attr("data-panel", "events") | |||
:attr("data- | |||
local grid = panel:tag("div"):addClass("sv-ref-grid") | local grid = panel:tag("div"):addClass("sv-ref-grid") | ||
for _, card in ipairs(events_cards) do | for _, card in ipairs(events_cards) do | ||
render_ref_card(grid, card, true) | |||
end | end | ||
end | end | ||
| Line 756: | Line 670: | ||
local debug = (_get_arg(frame, "debug") == "1") | local debug = (_get_arg(frame, "debug") == "1") | ||
local payload, err | local payload, err = _decode_json(raw_data) | ||
if not payload then | if not payload then | ||
local msg = mw.html.create("div") | local msg = mw.html.create("div") | ||
| Line 763: | Line 677: | ||
if debug then | if debug then | ||
local preview = | local preview = _strip_wrappers(raw_data) | ||
msg:tag("pre"):wikitext(mw.text.nowiki(preview:sub(1, 360))) | |||
msg:tag(" | msg:tag("div"):wikitext("len=" .. tostring(#preview)) | ||
msg:tag(" | |||
end | end | ||
| Line 786: | Line 699: | ||
local default_level = _to_int(level_obj.default, 1) | local default_level = _to_int(level_obj.default, 1) | ||
local max_level = _to_int(level_obj.max, _to_int(level_obj.base_max, 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 max_level < 1 then max_level = 1 end | ||
if default_level < 1 then default_level = 1 end | if default_level < 1 then default_level = 1 end | ||
| Line 797: | Line 711: | ||
}) | }) | ||
box.root:addClass("sv-skill-card") | box.root:addClass("sv-skill-card") | ||
box.top:addClass("sv-skill-top") | box.top:addClass("sv-skill-top") | ||
| Line 803: | Line 716: | ||
-- TOP | -- TOP | ||
box.top:node(_build_identity(identity, notes_wt)) | box.top:node(_build_identity(root_id, identity, notes_wt)) | ||
box.top:node(_build_meta_row(payload.meta_row)) | box.top:node(_build_meta_row(payload.meta_row)) | ||
local reqrow = _build_req_users(payload.requirements, payload.users) | |||
local reqrow = _build_req_users(root_id, payload.requirements, payload.users) | |||
if reqrow then box.top:node(reqrow) end | if reqrow then box.top:node(reqrow) end | ||
| Line 821: | Line 735: | ||
end | end | ||
-- Router | -- Router compatibility | ||
p.main = p.render | |||
return p | return p | ||
Revision as of 05:03, 19 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 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: Skills renderer (TemplateStyles-safe markup; no <details>/<button>/<a>).
local p = {}
local GI = require("Module:GameInfo")
local _file_exists_cache = {}
local function _trim(s)
if s == nil then return "" end
s = tostring(s)
s = s:gsub("^%s+", ""):gsub("%s+$", "")
return 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
n = math.floor(n + 0.0)
return n
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
-- -----------------------------------------------------------------------------
-- COMPAT / ARG + JSON HELPERS
-- -----------------------------------------------------------------------------
local function _get_arg(frame, key)
local args = frame and frame.args or {}
local v = _trim(args[key])
if v ~= "" then return v end
local parent = frame and frame.getParent and frame:getParent() or nil
local pargs = parent and parent.args or {}
v = _trim(pargs[key])
if v ~= "" then return v end
return ""
end
local function _strip_wrappers(raw)
raw = raw == nil and "" or tostring(raw)
-- Undo strip markers created by <nowiki>/<pre> etc.
if mw.text and mw.text.unstripNoWiki then
raw = mw.text.unstripNoWiki(raw)
end
if mw.text and mw.text.unstrip then
raw = mw.text.unstrip(raw)
end
raw = _trim(raw)
-- In case literal wrapper tags survive in some paths:
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)
if not ok then
return nil, "invalid json"
end
if type(decoded) ~= "table" then
return nil, "json is not object"
end
return decoded, nil
end
-- -----------------------------------------------------------------------------
-- FILE / ICON HELPERS
-- -----------------------------------------------------------------------------
local function _normalize_file_title(page)
page = _trim(page)
if page == "" then return nil end
-- Accept File: / Image: / bare names
if page:match("^[Ff]ile:") or page:match("^[Ii]mage:") then
-- Normalize to File:
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
-- Some installs are quirky with File: pages; try both .exists and .file.
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
-- -----------------------------------------------------------------------------
-- VALUE HELPERS
-- -----------------------------------------------------------------------------
-- Values are display-only:
-- - { text = "..." } = constant
-- - { series = [...] } = per-level; Lua emits data-series for JS to swap
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(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
-- -----------------------------------------------------------------------------
-- NOTES / DISCLOSE (NO <details>/<summary>)
-- -----------------------------------------------------------------------------
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(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(it.label_lines)
local link = _safe_tbl(it.link)
local page = _safe_str(link.page, "")
if page ~= "" then
-- Wikitext link (safe); label_node is HTML span that is allowed.
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(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
-- -----------------------------------------------------------------------------
-- BUILDERS
-- -----------------------------------------------------------------------------
local function _build_identity(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(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(cell.label_lines))
end
return meta
end
local function _build_level(level_obj)
level_obj = _safe_tbl(level_obj)
local default = _to_int(level_obj.default, 1)
local max = _to_int(level_obj.max, _to_int(level_obj.base_max, 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")
ui:tag("div")
:addClass("sv-level-label")
:wikitext("Level ")
:tag("span"):addClass("sv-level-num"):wikitext(tostring(default))
ui:tag("div"):addClass("sv-level-label"):wikitext(" / " .. tostring(max))
local slider = root:tag("div"):addClass("sv-level-slider")
slider:tag("input")
:attr("type", "range")
:addClass("sv-level-range")
:attr("min", "1")
:attr("max", tostring(max))
:attr("value", tostring(default))
:attr("step", "1")
:attr("aria-label", "Skill level select")
slider: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")
local v = col:tag("div"):addClass("sv-scaling-value")
_apply_value(v, scaling.modifier, level)
col:tag("div"):addClass("sv-scaling-label"):wikitext("Modifier")
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
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(root_id, tabs_obj, level)
tabs_obj = _safe_tbl(tabs_obj)
local mechanics = _safe_tbl(tabs_obj.mechanics or tabs_obj.Mechanics or tabs_obj["mechanics"])
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 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")
local panels = tabs:tag("div"):addClass("sv-tabs-panels")
local active_key = "mechanics"
for _, spec in ipairs(tab_specs) do
local t = list:tag("span")
:addClass("sv-tab")
:attr("role", "button")
:attr("tabindex", spec.key == active_key and "0" or "-1")
:attr("data-tab", spec.key)
:attr("aria-selected", spec.key == active_key and "true" or "false")
if spec.key == active_key then
t:addClass("sv-tab--active")
end
t:wikitext(mw.text.nowiki(spec.title))
end
-- Panel: Mechanics
do
local panel = panels:tag("div")
:addClass("sv-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
-- Panel: Keywords
do
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:addClass("sv-hidden")
:attr("data-panel", "keywords")
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
local pill = pills:tag("span"):addClass("sv-pill")
-- If caller supplies "Domain|Key", we can call {{def|Domain|Key}}.
if s:find("|", 1, true) then
pill:wikitext("{{def|" .. s .. "}}")
else
pill:wikitext(mw.text.nowiki(s))
end
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")
-- Title (wikitext link; no raw <a>)
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
-- Optional stats line
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
-- Panel: Effects
do
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:addClass("sv-hidden")
:attr("data-panel", "effects")
local grid = panel:tag("div"):addClass("sv-ref-grid")
for _, card in ipairs(effects_cards) do
render_ref_card(grid, card, false)
end
end
-- Panel: Events
do
local panel = panels:tag("div")
:addClass("sv-tabpanel")
:addClass("sv-hidden")
:attr("data-panel", "events")
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
-- -----------------------------------------------------------------------------
-- PUBLIC ENTRYPOINT
-- -----------------------------------------------------------------------------
function p.render(frame)
frame = frame or mw.getCurrentFrame()
local raw_data = _get_arg(frame, "data")
local notes_wt = _get_arg(frame, "notes")
local debug = (_get_arg(frame, "debug") == "1")
local payload, err = _decode_json(raw_data)
if not payload then
local msg = mw.html.create("div")
:addClass("sv-gi-error")
:wikitext("GameInfo/Skills error: " .. tostring(err))
if debug then
local preview = _strip_wrappers(raw_data)
msg:tag("pre"):wikitext(mw.text.nowiki(preview:sub(1, 360)))
msg:tag("div"):wikitext("len=" .. tostring(#preview))
end
return GI.styles(frame) .. tostring(msg)
end
local identity = _safe_tbl(payload.identity)
local display_name = _safe_str(identity.display_name, "Unknown Skill")
local idx = _to_int(_get_arg(frame, "index"), 1)
if idx < 1 then idx = 1 end
local root_id = _get_arg(frame, "id")
if root_id == "" then
root_id = "sv-skill-" .. _slugify(display_name) .. "-" .. tostring(idx)
end
local level_obj = _safe_tbl(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
local box = GI.new_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")
-- TOP
box.top:node(_build_identity(root_id, identity, notes_wt))
box.top:node(_build_meta_row(payload.meta_row))
local reqrow = _build_req_users(root_id, payload.requirements, payload.users)
if reqrow then box.top:node(reqrow) end
-- BOTTOM
local level_panel, actual_default = _build_level(payload.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))
local tabs_obj = _safe_tbl(payload.tabs)
if next(tabs_obj) == nil then tabs_obj = payload end
box.bottom:node(_build_tabs(root_id, tabs_obj, actual_default))
return GI.styles(frame) .. tostring(box.root)
end
-- Router compatibility
p.main = p.render
return p