Tags: Mobile edit Mobile web edit |
Tags: Mobile edit Mobile web edit |
| Line 1: |
Line 1: |
| | -- Module:GameInfo/Skills |
| | -- Phase 4.1 — Schema 1 renderer (legacy shape) |
| | -- |
| | -- Keyword pills policy: |
| | -- Accept ONLY "Domain\u007CKey" in JSON. |
| | -- After jsonDecode, this becomes "Domain|Key" and is expanded via {{def|Domain|Key}}. |
| | -- No support for: |
| | -- - "<nowiki>...</nowiki>" wrapped data |
| | -- - "Domain\\u007CKey" (double-escaped) |
| | -- - "{{def|...}}" template-shaped strings inside JSON |
| | -- |
| | -- This module intentionally does NOT attempt any workarounds. |
| | |
| local p = {} | | local p = {} |
|
| |
|
| Line 43: |
Line 56: |
| 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("|", "|"):gsub("|", "|")
| |
| 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 170: |
Line 68: |
| return nil, "json is not object", raw | | return nil, "json is not object", raw |
| end | | end |
| | | if decoded.schema ~= 1 then |
| local schema = decoded.schema | | return nil, "unsupported schema=" .. tostring(decoded.schema), raw |
| 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 261: |
Line 153: |
| end | | end |
|
| |
|
| local function _meta_lines(frame, lines) | | local function _meta_lines(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 |
| local wt = _render_def_or_text(frame, s) or mw.text.nowiki("—")
| | wrap:tag("span"):wikitext(mw.text.nowiki(s)) |
| 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 |
| | return wrap |
| | end |
| | |
| | -- Keywords pill policy: ONLY Domain|Key (coming from Domain\u007CKey in JSON). |
| | 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 | | end |
|
| |
|
| return wrap | | return mw.text.nowiki(pill_text) |
| end | | end |
|
| |
|
| Line 329: |
Line 236: |
| end | | end |
|
| |
|
| local function _build_grouped_disclose(frame, root_id, key, 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 |
| Line 374: |
Line 281: |
| local li = ul:tag("li") | | local li = ul:tag("li") |
|
| |
|
| local label_node = _meta_lines(frame, it.label_lines) | | local label_node = _meta_lines(it.label_lines) |
|
| |
|
| local link = _safe_tbl(it.link) | | local link = _safe_tbl(it.link) |
| Line 389: |
Line 296: |
| end | | end |
|
| |
|
| local function _build_req_users(frame, root_id, requirements, users) | | local function _build_req_users(root_id, requirements, users) |
| requirements = _safe_tbl(requirements) | | requirements = _safe_tbl(requirements) |
| users = _safe_tbl(users) | | users = _safe_tbl(users) |
|
| |
|
| local req_box, req_n = _build_grouped_disclose(frame, root_id, "req", "Requirements", _safe_tbl(requirements.groups)) | | local req_box, req_n = _build_grouped_disclose(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)) | | 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 | | if req_n == 0 and usr_n == 0 then return nil end |
| Line 404: |
Line 311: |
| end | | end |
|
| |
|
| local function _build_identity(frame, root_id, identity, notes_wt) | | local function _build_identity(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 436: |
Line 343: |
| end | | end |
|
| |
|
| local function _build_meta_row(frame, meta_row) | | local function _build_meta_row(meta_row) |
| meta_row = _safe_tbl(meta_row) | | meta_row = _safe_tbl(meta_row) |
|
| |
|
| Line 456: |
Line 363: |
|
| |
|
| 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(frame, cell.label_lines)) | | wrap:tag("div"):addClass("sv-meta-text"):node(_meta_lines(cell.label_lines)) |
| end | | end |
|
| |
|
| Line 462: |
Line 369: |
| end | | end |
|
| |
|
| -- Level normalization (schema 1): base_max + max | | -- Level normalization: |
| | -- - 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 646: |
Line 556: |
| 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_def_or_text(frame, kw) | | local wt = _render_keyword_pill(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 723: |
Line 633: |
|
| |
|
| 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 885: |
Line 644: |
| 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 903: |
Line 657: |
| end | | end |
|
| |
|
| -- Slider default = natural cap (base_max if present; else max)
| |
| local default_level, _base_max, max_level = _normalize_level(payload.level) | | local default_level, _base_max, max_level = _normalize_level(payload.level) |
|
| |
|
| Line 917: |
Line 670: |
| box.bottom:addClass("sv-skill-bottom") | | box.bottom:addClass("sv-skill-bottom") |
|
| |
|
| box.top:node(_build_identity(frame, root_id, identity, notes_wt)) | | box.top:node(_build_identity(root_id, identity, notes_wt)) |
| box.top:node(_build_meta_row(frame, payload.meta_row)) | | box.top:node(_build_meta_row(payload.meta_row)) |
|
| |
|
| local reqrow = _build_req_users(frame, root_id, payload.requirements, payload.users) | | local reqrow = _build_req_users(root_id, payload.requirements, payload.users) |
| if reqrow then box.top:node(reqrow) end | | if reqrow then box.top:node(reqrow) end |
|
| |
|