打开/关闭菜单
8
231
3
1270
夜幕之下 Wiki - Reign of Nightfall 中文资料站
打开/关闭外观设置菜单
打开/关闭个人菜单
未登录
未登录用户的IP地址会在进行任意编辑后公开展示。

模块:CardData

来自夜幕之下 Wiki - Reign of Nightfall 中文资料站

此模块的文档可以在模块:CardData/doc创建

-- Module:CardData
-- 用法:{{#invoke:CardData|render|复仇童谣}}

local p = {}
local AttrGrowth = require("Module:StyleAttributeGrowth")
local WeaponAttack = require("Module:WeaponAttackData")

local function val(v, fallback)
    if v == nil or v == "" then return fallback or "—" end
    return v
end

local function rarityStars(n)
    n = tonumber(n) or 0
    return string.rep("★", n)
end

local function parseJson(str)
    if not str or str == "" then return nil end
    local ok, result = pcall(mw.text.jsonDecode, str)
    if ok then return result else return nil end
end

local function makeTitle(titleText, subtitleText, extraClass)
    local div = mw.html.create("div")
        :addClass("card_content-item-title")
        :addClass("card_content-item-title--mt")
    if extraClass then div:addClass(extraClass) end
    div:wikitext(titleText)
    div:tag("span"):addClass("card_content-item-subtitle"):wikitext(subtitleText)
    return div
end

local function makeHr(extraClass)
    local div = mw.html.create("div"):addClass("card_content-item-hr")
    if extraClass then div:addClass(extraClass) end
    return div
end

-- 单条属性行:图标 + 标签 + 值
local function makeAttrRow(label, value)
    local item = mw.html.create("div"):addClass("card_content_attribute-item")
    local titleDiv = item:tag("div"):addClass("card_content_attribute-title")
    titleDiv:tag("div"):addClass("card_content_attribute-icon")
    titleDiv:tag("div"):wikitext(" " .. label)
    item:tag("div"):wikitext(value or "—")
    return item
end

local function formatAttrNumber(value)
    local n = tonumber(value)
    if not n then return "—" end
    return mw.getContentLanguage():formatNum(math.floor(n))
end

local function makeDynamicAttrRow(label, key, value)
    local item = mw.html.create("div"):addClass("card_content_attribute-item style_attr-stat-row")
    local titleDiv = item:tag("div"):addClass("card_content_attribute-title")
    titleDiv:tag("div"):addClass("card_content_attribute-icon")
    titleDiv:tag("div"):wikitext(" " .. label)
    item:tag("div")
        :addClass("style_attr-stat-value")
        :attr("data-style-attr-stat", key)
        :wikitext(formatAttrNumber(value))
    return item
end

local function addAttrDataAttrs(node, payload)
    local data = payload and payload.data or {}
    node:attr("data-style-attr-growth", "1")
        :attr("data-default-level", data.default_level or 100)
        :attr("data-formula-version", data.formula_version or "hero_level_growth_v1")
        :attr("data-base-hp", data.base_hp)
        :attr("data-base-atk", data.base_atk)
        :attr("data-base-def", data.base_def)
        :attr("data-rate-hp", data.hp_rate)
        :attr("data-rate-atk", data.atk_rate)
        :attr("data-rate-def", data.def_rate)
        :attr("data-break-hp", data.break_hp)
        :attr("data-break-atk", data.break_atk)
        :attr("data-break-def", data.break_def)
end

local function makeStyleAttributeGrowthSection(card)
    local payload = AttrGrowth.payload(card, 100)
    local section = mw.html.create("div"):addClass("style_attr-growth")

    section:node(makeTitle("等级", "Level", "style_attr-title"))
    section:node(makeHr())

    if not payload or not payload.result then
        section:tag("div"):addClass("style_attr-empty"):wikitext("(属性成长数据待补充)")
        return section
    end

    addAttrDataAttrs(section, payload)

    local result = payload.result
    local levelRow = section:tag("div"):addClass("style_attr-level-row")
    local levelDisplay = levelRow:tag("div"):addClass("style_attr-level-display")
    levelDisplay:tag("span"):addClass("style_attr-title-lv"):wikitext("Lv.")
    levelDisplay:tag("span"):addClass("style_attr-title-level")
        :attr("data-style-attr-level-number", "1")
        :wikitext(tostring(result.level))
    levelRow:tag("span"):addClass("style_attr-break style_attr-break--title")
        :attr("data-style-attr-break-label", "1")
        :wikitext("晋升 " .. tostring(result.break_count))

    local slider = section:tag("div")
        :addClass("style_attr-slider")
        :attr("role", "slider")
        :attr("tabindex", "0")
        :attr("aria-label", "等级")
        :attr("aria-valuemin", "1")
        :attr("aria-valuemax", "100")
        :attr("aria-valuenow", tostring(result.level))
        :attr("data-style-attr-slider", "1")
    slider:tag("div"):addClass("style_attr-slider-track")
        :tag("div"):addClass("style_attr-slider-fill"):css("width", "100%")
    slider:tag("div"):addClass("style_attr-slider-thumb"):css("left", "100%")

    local presets = section:tag("div"):addClass("style_attr-presets")
    local presetLevels = { 1, 20, 40, 60, 80, 100 }
    for _, level in ipairs(presetLevels) do
        presets:tag("div")
            :addClass("style_attr-preset")
            :attr("role", "button")
            :attr("tabindex", "0")
            :attr("data-style-attr-preset", tostring(level))
            :wikitext(tostring(level))
    end

    section:node(makeDynamicAttrRow("生命", "hp", result.hp))
    section:node(makeDynamicAttrRow("攻击", "atk", result.atk))
    section:node(makeDynamicAttrRow("防御", "def", result.def))

    return section
end

local collectionQualityMeta = {
    [1] = { code = "C", class = "style_collection-quality--c" },
    [2] = { code = "B", class = "style_collection-quality--b" },
    [3] = { code = "A", class = "style_collection-quality--a" },
    [4] = { code = "S", class = "style_collection-quality--s" },
}

local collectionAttrOrder = {
    [27] = 1, [28] = 2, [29] = 3,
    [23] = 4, [21] = 5, [24] = 6,
    [8] = 7, [9] = 8, [12] = 9, [13] = 10,
    [14] = 11, [11] = 12, [22] = 13, [58] = 14, [59] = 15, [60] = 16,
}

local collectionPercentAttrs = {
    [8] = true, [9] = true, [11] = true, [12] = true,
    [13] = true, [14] = true, [21] = true, [22] = true,
    [23] = true, [24] = true, [58] = true, [59] = true, [60] = true,
}

local function collectionRows(input)
    if type(input) ~= "table" then return {} end
    return input
end

local function collectionFormatPercent(raw)
    local n = (tonumber(raw) or 0) / 100
    local text
    if math.floor(n) == n then
        text = string.format("%.0f", n)
    elseif math.floor(n * 10) == n * 10 then
        text = string.format("%.1f", n)
    else
        text = string.format("%.2f", n)
    end
    return "+" .. text .. "%"
end

local function collectionFormatAttr(attrId, raw)
    local n = tonumber(raw)
    if not n then return "—" end
    if collectionPercentAttrs[tonumber(attrId)] then
        return collectionFormatPercent(n)
    end
    return "+" .. mw.getContentLanguage():formatNum(math.floor(n))
end

local function collectionTermDescription(term)
    if not term then return "—" end
    if tonumber(term.effect_type) == 1 and term.attr_id and term.attr_value then
        return val(term.attr_name or term.name, "属性") .. " " .. collectionFormatAttr(term.attr_id, term.attr_value)
    end
    return val(term.description, "效果描述待补充")
end

local function sortedCollectionFixedRows(rows)
    local out = {}
    for _, row in ipairs(collectionRows(rows)) do
        if type(row) == "table" then table.insert(out, row) end
    end
    table.sort(out, function(a, b)
        local ap = tonumber(a.position) or 99
        local bp = tonumber(b.position) or 99
        if ap ~= bp then return ap < bp end
        local as = tonumber(a.slot_index) or 99
        local bs = tonumber(b.slot_index) or 99
        if as ~= bs then return as < bs end
        return tostring(a.attr_name or "") < tostring(b.attr_name or "")
    end)
    return out
end

local function aggregateCollectionFixed(rows)
    local byAttr = {}
    for _, row in ipairs(sortedCollectionFixedRows(rows)) do
        local attrId = tonumber(row.attr_id)
        if attrId then
            local item = byAttr[attrId]
            if not item then
                item = {
                    attr_id = attrId,
                    attr_name = val(row.attr_name, "属性" .. tostring(attrId)),
                    count = 0,
                    raw = 0,
                }
                byAttr[attrId] = item
            end
            item.count = item.count + 1
            local raw = tostring(row.source_raw or ""):match("#(%d+)$")
            item.raw = item.raw + (tonumber(raw) or 0)
        end
    end
    local out = {}
    for _, item in pairs(byAttr) do table.insert(out, item) end
    table.sort(out, function(a, b)
        local ao = collectionAttrOrder[a.attr_id] or 999
        local bo = collectionAttrOrder[b.attr_id] or 999
        if ao ~= bo then return ao < bo end
        return tostring(a.attr_name) < tostring(b.attr_name)
    end)
    return out
end

local function sortedCollectionTerms(card)
    local terms = {}
    local pools = collectionRows(card.collection_refine_pools)
    for _, pool in ipairs(pools) do
        for _, term in ipairs(collectionRows(pool.terms)) do
            if type(term) == "table" then
                term.__pool_id = pool.pool_id or card.collection_refine_pool
                table.insert(terms, term)
            end
        end
    end
    table.sort(terms, function(a, b)
        local aq = tonumber(a.quality) or 0
        local bq = tonumber(b.quality) or 0
        if aq ~= bq then return aq > bq end
        local ae = tonumber(a.effect_id) or 999
        local be = tonumber(b.effect_id) or 999
        if ae ~= be then return ae < be end
        return tostring(a.name or "") < tostring(b.name or "")
    end)
    return terms
end

local function collectionFormatWeightPercent(weight, total)
    local w = tonumber(weight) or 0
    local t = tonumber(total) or 0
    if w <= 0 or t <= 0 then
        return "0%"
    end
    local p = w * 100 / t
    if p < 1 then
        return string.format("%.1f%%", p)
    elseif p < 10 then
        return string.format("%.1f%%", p)
    end
    return string.format("%.0f%%", p)
end

local function renderCollectionTerm(parent, term, totalWeight)
    local quality = collectionQualityMeta[tonumber(term.quality)] or collectionQualityMeta[1]
    local node = parent:tag("div")
        :addClass("style_collection-term")
        :addClass("style_collection-term--q" .. tostring(tonumber(term.quality) or 0))
    if (tonumber(term.first_weight) or 0) > 0 then
        node:addClass("style_collection-term--default")
    end
    local head = node:tag("div"):addClass("style_collection-term-head")
    head:tag("span")
        :addClass("style_collection-quality " .. quality.class)
        :wikitext(quality.code)
    local title = head:tag("span"):addClass("style_collection-term-title")
    title:tag("span"):addClass("style_collection-term-name"):wikitext(val(term.name, "词条"))
    if (tonumber(term.first_weight) or 0) > 0 then
        title:tag("span"):addClass("style_collection-term-default"):wikitext("初始")
    end
    local weight = head:tag("span"):addClass("style_collection-term-weight")
    weight:tag("span"):addClass("style_collection-term-weight-label"):wikitext("权重")
    weight:tag("span"):addClass("style_collection-term-weight-value"):wikitext(collectionFormatWeightPercent(term.normal_weight, totalWeight))
    node:tag("div"):addClass("style_collection-term-desc"):wikitext(collectionTermDescription(term))
end

local function makeStyleCollectionSection(card)
    local fixedRows = sortedCollectionFixedRows(card.collection_fixed_attributes)
    local terms = sortedCollectionTerms(card)
    if #fixedRows == 0 and #terms == 0 then
        local section = mw.html.create("div"):addClass("style_collection")
        section:node(makeTitle("映像", "Reflection"))
        section:node(makeHr())
        section:tag("div"):addClass("style_attr-empty"):wikitext("(映像数据待补充)")
        return section
    end

    local section = mw.html.create("div")
        :addClass("style_collection")
        :attr("data-style-collection", "1")
    section:node(makeTitle("映像", "Reflection"))
    section:node(makeHr())

    local summary = section:tag("div"):addClass("style_collection-summary")
    section:attr("data-style-collection-expanded", "false")
    for _, item in ipairs(aggregateCollectionFixed(fixedRows)) do
        local row = summary:tag("div"):addClass("card_content_attribute-item style_collection-summary-row")
        local titleDiv = row:tag("div"):addClass("card_content_attribute-title")
        titleDiv:tag("div"):addClass("card_content_attribute-icon")
        titleDiv:tag("div"):wikitext(" " .. item.attr_name)
        local value = row:tag("div"):addClass("style_collection-summary-value")
        value:tag("span"):addClass("style_collection-summary-count"):wikitext("×" .. tostring(item.count))
        value:tag("span"):addClass("style_collection-summary-number"):wikitext(collectionFormatAttr(item.attr_id, item.raw))
    end

    local toggle = section:tag("div")
        :addClass("style_collection-toggle")
        :attr("role", "button")
        :attr("tabindex", "0")
        :attr("data-style-collection-toggle", "1")
    toggle:tag("span"):addClass("style_collection-toggle-text"):wikitext("展开映像详情")
    toggle:tag("span"):addClass("style_collection-toggle-icon"):wikitext("+")

    local attrDetails = section:tag("div")
        :addClass("style_collection-attr-details")
        :attr("data-style-collection-extra", "1")
    local positions = attrDetails:tag("div"):addClass("style_collection-slots")
    for position = 1, 3 do
        local slot = positions:tag("div"):addClass("style_collection-slot")
        slot:tag("div"):addClass("style_collection-slot-title"):wikitext("映像 " .. tostring(position))
        for _, row in ipairs(fixedRows) do
            if tonumber(row.position) == position then
                local attr = slot:tag("div"):addClass("style_collection-slot-attr")
                attr:tag("span"):wikitext(val(row.attr_name, "属性"))
                local raw = tostring(row.source_raw or ""):match("#(%d+)$")
                attr:tag("span"):addClass("style_collection-slot-value"):wikitext(collectionFormatAttr(row.attr_id, raw))
            end
        end
    end

    local featured = section:tag("div"):addClass("style_collection-featured")
    local totalNormalWeight = 0
    for _, term in ipairs(terms) do
        totalNormalWeight = totalNormalWeight + (tonumber(term.normal_weight) or 0)
    end
    local featuredCount = 0
    local extraCount = 0
    for _, term in ipairs(terms) do
        local q = tonumber(term.quality) or 0
        if q >= 3 then
            renderCollectionTerm(featured, term, totalNormalWeight)
            featuredCount = featuredCount + 1
        else
            extraCount = extraCount + 1
        end
    end
    if featuredCount == 0 then
        featured:tag("div"):addClass("style_attr-empty"):wikitext("(暂无 S / A 词条)")
    end

    local extraTerms = section:tag("div")
        :addClass("style_collection-extra-terms")
        :attr("data-style-collection-extra", "1")
    if extraCount == 0 then
        extraTerms:tag("div"):addClass("style_attr-empty"):wikitext("(暂无更多词条)")
    else
        for _, term in ipairs(terms) do
            local q = tonumber(term.quality) or 0
            if q < 3 then
                renderCollectionTerm(extraTerms, term, totalNormalWeight)
            end
        end
    end

    return section
end


local function makeInfoSection(intel, supply, execute, strategy, rarity)
    local infoDiv = mw.html.create("div"):addClass("card_content_info")

    local rarityItem = infoDiv:tag("div"):addClass("card_content_info-item")
    rarityItem:tag("div"):wikitext("稀有度")
    rarityItem:tag("div"):wikitext(rarityStars(rarity))

    local dims = {
        { label = "情报", value = intel    },
        { label = "物资", value = supply   },
        { label = "执行", value = execute  },
        { label = "策略", value = strategy },
    }
    for _, dim in ipairs(dims) do
        local item = infoDiv:tag("div"):addClass("card_content_info-item")
        item:tag("div"):wikitext(dim.label)
        local n = tonumber(dim.value)
        item:tag("div"):wikitext(n and ("※" .. n) or "—")
    end

    return infoDiv
end

local function makeTriggerLabel(triggerType, triggerValue)
    local span = mw.html.create("span"):addClass("card_content_skill-meta-val")
    local trigger = triggerType ~= nil and mw.text.trim(tostring(triggerType)) or ""
    if trigger == "normal_attack" then
        span:wikitext("每进行")
        span:tag("span"):addClass("card_accent"):wikitext(tostring(triggerValue or "?"))
        span:wikitext("次普攻")
    elseif trigger == "interval" then
        span:wikitext("冷却时间")
        span:tag("span"):addClass("card_accent"):wikitext(tostring(triggerValue or "?"))
        span:wikitext("秒")
    elseif trigger == "hit_taken" or trigger == "每受到x次攻击" or trigger == "每受到X次攻击" then
        span:wikitext("每受到")
        span:tag("span"):addClass("card_accent"):wikitext(tostring(triggerValue or "?"))
        span:wikitext("次攻击")
    else
        span:wikitext(val(triggerType))
    end
    return span
end

local function normalizeTags(tagsValue)
    if not tagsValue then return nil end
    if type(tagsValue) == "table" then return tagsValue end
    if type(tagsValue) == "string" then return parseJson(tagsValue) end
    return nil
end

local function makeTagsRow(tagsValue, rowClass, tagClass)
    local div = mw.html.create("div"):addClass(rowClass or "card_content_skill-tags")
    local tags = normalizeTags(tagsValue)
    if tags and type(tags) == "table" then
        for _, tag in ipairs(tags) do
            local text = mw.text.trim(tostring(tag or ""))
            if text ~= "" then
                div:tag("div"):addClass(tagClass or "card_content_skill-tag"):wikitext(text)
            end
        end
    end
    return div
end

local function makeStyleTagsRow(tagsValue)
    local tags = normalizeTags(tagsValue)
    if not tags or type(tags) ~= "table" or #tags == 0 then return nil end
    return makeTagsRow(tags, "card_content_style-tags card_content_skill-tags", "card_content_style-tag card_content_skill-tag")
end

local function skillParamRows(levelsJson)
    local rows = parseJson(levelsJson)
    if not rows or type(rows) ~= "table" then return {}, {} end

    local ordered = {}
    local byIndex = {}
    for idx, row in ipairs(rows) do
        local paramIndex = tonumber(row.source_param_index or row.sort or idx) or idx
        row.__param_index = paramIndex
        table.insert(ordered, row)
        byIndex[paramIndex] = row
    end

    table.sort(ordered, function(a, b)
        local ai = tonumber(a.__param_index) or 9999
        local bi = tonumber(b.__param_index) or 9999
        if ai ~= bi then return ai < bi end
        return tostring(a.name or "") < tostring(b.name or "")
    end)

    return ordered, byIndex
end

local function skillParamValue(paramRow, level)
    if not paramRow or type(paramRow.levels) ~= "table" then return "—" end
    local lv = tonumber(level) or 1
    local value = paramRow.levels[lv]
    if value == nil or value == "" then
        value = paramRow.levels[1]
    end
    if value == nil or value == "" then return "—" end
    return tostring(value)
end

local function addSkillDescriptionText(node, text)
    if text and text ~= "" then node:wikitext(text) end
end

local function addEffectTemplateText(node, template)
    local text = mw.text.trim(tostring(template or ""))
    if text == "" then return end

    local pos = 1
    while true do
        local fStart, fEnd, effectName = string.find(text, "{fx:([^}]+)}", pos)
        if not fStart then break end

        addSkillDescriptionText(node, string.sub(text, pos, fStart - 1))
        local label = mw.text.trim(effectName or "")
        local effect = node:tag("span")
            :addClass("card_content_skill-effect-term")
            :attr("data-skill-effect", label)
        effect:wikitext("[" .. label .. "]")
        pos = fEnd + 1
    end

    addSkillDescriptionText(node, string.sub(text, pos))
end

local function makeSkillDescription(skillData, displayLevel)
    local wrap = mw.html.create("div"):addClass("card_content_skill_effect")
    local template = ""
    if skillData then
        if skillData.description_template ~= nil then
            template = mw.text.trim(tostring(skillData.description_template))
        end
        if template == "" and skillData.description ~= nil then
            template = mw.text.trim(tostring(skillData.description))
        end
    end
    if template == "" then return wrap end

    local _, paramsByIndex = skillParamRows(skillData.levels)
    local pos = 1
    while true do
        local pStart, pEnd, pIndex = string.find(template, "{p(%d+)}", pos)
        local fStart, fEnd, effectName = string.find(template, "{fx:([^}]+)}", pos)

        if not pStart and not fStart then break end

        local useParam = pStart and (not fStart or pStart < fStart)
        local startPos = useParam and pStart or fStart
        local endPos = useParam and pEnd or fEnd
        addSkillDescriptionText(wrap, string.sub(template, pos, startPos - 1))

        if useParam then
            local index = tonumber(pIndex)
            local value = skillParamValue(paramsByIndex[index], displayLevel or 1)
            local span = wrap:tag("span")
                :addClass("card_content_skill-param")
                :attr("data-skill-param-index", tostring(index))
                :attr("data-skill-param-level", tostring(displayLevel or 1))
            span:wikitext(value)
            span:tag("sup")
                :addClass("card_content_skill-param-index")
                :wikitext(tostring(index))
        else
            addEffectTemplateText(wrap, string.sub(template, fStart, fEnd))
        end

        pos = endPos + 1
    end

    addSkillDescriptionText(wrap, string.sub(template, pos))
    return wrap
end

local function makeUpgradeTable(levelsJson)
    local wrap = mw.html.create("div"):addClass("card_content_skill-upgrade")
    local rows = parseJson(levelsJson)
    if not rows or type(rows) ~= "table" or #rows == 0 then return wrap end

    local maxCols = 0
    for _, row in ipairs(rows) do
        if type(row.levels) == "table" then
            local displayCols = #row.levels >= 10 and (#row.levels - 1) or #row.levels
            if displayCols > maxCols then
                maxCols = displayCols
            end
        end
    end

    local DATA_COLS, gridClass
    if maxCols > 8 then
        DATA_COLS = 9
        gridClass = "card_content_skill-upgrade-grid card_content_skill-upgrade-grid--10"
    else
        DATA_COLS = 8
        gridClass = "card_content_skill-upgrade-grid"
    end

    local grid = wrap:tag("div"):addClass(gridClass)

    grid:tag("div")
    for i = 2, DATA_COLS + 1 do
        grid:tag("div"):addClass("card_content_skill-upgrade-lv"):wikitext(tostring(i))
    end

    for idx, row in ipairs(rows) do
        local labelCell = grid:tag("div"):addClass("card_content_skill-upgrade-row-label")
        labelCell:tag("span"):addClass("card_content_skill-upgrade-badge"):wikitext(tostring(idx))
        labelCell:tag("span"):addClass("card_content_skill-upgrade-badge-text"):wikitext(val(row.name, ""))
        labelCell:tag("span"):addClass("card_content_skill-upgrade-arrow"):wikitext("→")

        local levels = (type(row.levels) == "table") and row.levels or {}
        local offset = #levels >= 10 and 1 or 0
        for col = 1, DATA_COLS do
            local v = levels[col + offset]
            local cell = grid:tag("div"):addClass("card_content_skill-upgrade-val")
            if v == nil or v == "" then
                cell:addClass("card_content_skill-upgrade-val--null"):wikitext("—")
            else
                cell:addClass("card_accent"):wikitext(tostring(v))
            end
        end
    end

    return wrap
end

local function makeNameIcon(name, extraClass, iconSize)
    local icon = mw.html.create("div"):addClass("card_content_name-icon")
    if extraClass then icon:addClass(extraClass) end

    local iconName = name == nil and "" or mw.text.trim(tostring(name))
    local size = iconSize or "32x32px"
    if iconName ~= "" and iconName ~= "—" then
        icon:wikitext("[[文件:图标_" .. iconName .. ".png|" .. size .. "|link=]]")
    end

    return icon
end

local function makeSkillIcon(skillData, skillType, stylename)
    local icon = mw.html.create("div"):addClass("card_content_skill-icon")
    local skillName = skillData and skillData.name and tostring(skillData.name) or ""
    local styleName = stylename and tostring(stylename) or ""
    if skillName ~= "" and styleName ~= "" and styleName ~= "—" then
        icon:wikitext("[[文件:技能_" .. styleName .. "_" .. skillName .. ".png|48x48px]]")
    end
    return icon
end

local function fetchWeaponAttackProfile(frame, card, skillData)
    local mounted = WeaponAttack.pickProfile(card.weapon_attack_profiles, skillData)
    if mounted then return mounted end

    local sourceStyleId = card.source_style_id
    if (sourceStyleId == nil or sourceStyleId == "") and card.attribute_growths and type(card.attribute_growths) == "table" and card.attribute_growths[1] then
        sourceStyleId = card.attribute_growths[1].source_style_id
    end
    if sourceStyleId == nil or sourceStyleId == "" then return nil end

    local profileUrl = "https://data.saltedkiss.org/items/weapon_attack_profiles"
        .. "?fields=status,source_style_id,normal_skill_id,normal_skill_name,weapon_type,attack_range"
        .. ",avg_ms_per_hit,hit_events_per_sec,action_cycles_per_sec,real_attack_cycle_ms"
        .. ",hit_event_count,reload_count,reload_time_ms,fire_frames"
        .. ",aim_target_rule,aim_time_ms,base_atk_spd_for_aim,real_aim_time_baseline_ms,real_aim_time_formula"
        .. ",normal_action_ms_raw,normal_action_ms_tick,normal_action_tick_formula,normal_hit_event_count"
        .. ",reload_per_action_ms,sustained_cycle_ms_no_aim,sustained_actions_per_sec_no_aim,sustained_hit_events_per_sec_no_aim"
        .. ",first_magazine_cycle_ms_with_aim,first_magazine_actions_per_sec_with_aim,first_magazine_hit_events_per_sec_with_aim"
        .. ",speed_formula_version,speed_formula_summary"
        .. ",verification_status,video_observed_hits_per_sec,verification_note"
        .. ",template.template_name,template.weapon_type,template.avg_ms_per_hit"
        .. ",template.hit_events_per_sec,template.real_attack_cycle_ms,template.attack_range,template.hit_count"
        .. ",template.reload_count,template.reload_time_ms,template.fire_frames_pattern"
        .. "&filter[source_style_id][_eq]=" .. mw.uri.encode(tostring(sourceStyleId), "QUERY")
        .. "&filter[status][_eq]=published"
        .. "&limit=1"

    local rawProfile = frame:preprocess(
        '{{#get_web_data:url=' .. profileUrl
        .. '|format=text|data=responseText=__text}}'
        .. '{{#external_value:responseText}}'
    )
    rawProfile = mw.text.trim(rawProfile or "")
    local parsedProfile = parseJson(rawProfile)
    if parsedProfile and type(parsedProfile.data) == "table" then
        return parsedProfile.data[1]
    end
    return nil
end

local function makeSkillCard(skillData, skillType, stylename, weaponAttackPayload)
    local card = mw.html.create("div"):addClass("card_content_skill-card")
    local left = card:tag("div"):addClass("card_content_skill-left")
    local titleRow = left:tag("div"):addClass("card_content_skill-title")
    titleRow:node(makeSkillIcon(skillData, skillType, stylename))
    local titleText = titleRow:tag("div"):addClass("card_content_skill-title-text")
    if weaponAttackPayload then titleText:addClass("card_weapon-popover-root") end
    local nameRow = titleText:tag("div")
    nameRow:tag("span"):addClass("card_content_skill-name"):wikitext(val(skillData.name))
    local typeSpan = nameRow:tag("span"):addClass("card_content_skill-type card_accent--type")
    if skillType == "normal_attack" and weaponAttackPayload then
        typeSpan:wikitext("「")
        typeSpan:node(WeaponAttack.makeTrigger(val(skillData.type), weaponAttackPayload, "card_weapon-trigger--type"))
        typeSpan:wikitext("」")
    else
        typeSpan:wikitext("「" .. val(skillData.type) .. "」")
    end
    local metaRow = titleText:tag("div"):addClass("card_content_skill-meta")
    if skillType == "normal_attack" then
        metaRow:wikitext("使用武器 ")
        local weaponSpan = metaRow:tag("span"):addClass("card_content_skill-meta-val card_accent")
        if weaponAttackPayload then
            weaponSpan:node(WeaponAttack.makeTrigger(val(skillData.weapon), weaponAttackPayload, "card_weapon-trigger--weapon"))
        else
            weaponSpan:wikitext(val(skillData.weapon))
        end
    elseif skillType == "passive" then
        metaRow:wikitext("触发条件 ")
        metaRow:node(makeTriggerLabel(skillData.trigger_type, skillData.trigger_value))
    elseif skillType == "ultimate" then
        metaRow:wikitext("欲火消耗 ")
        metaRow:tag("span"):addClass("card_content_skill-meta-val")
            :tag("span"):addClass("card_accent"):wikitext(val(skillData.desire_cost))
    end
    if skillType == "passive" or skillType == "ultimate" then
        left:node(makeTagsRow(skillData.tags))
    end
    if skillType == "normal_attack" and weaponAttackPayload then
        titleText:node(WeaponAttack.makePopover(weaponAttackPayload))
    end
    left:node(makeSkillDescription(skillData, 1))
    card:node(makeUpgradeTable(skillData.levels))
    return card
end

local function makeFeatList(stagesJson)
    local ul = mw.html.create("ul"):addClass("card_content_feat-ul")
    local stages = parseJson(stagesJson)
    if not stages or type(stages) ~= "table" then
        ul:tag("li"):wikitext("—")
        return ul
    end
    for _, stage in ipairs(stages) do
        local li  = ul:tag("li"):addClass("card_content_feat-list")
        local row = li:tag("div"):addClass("card_content_feat-row")
        local sw  = row:tag("div"):addClass("card_content_feat-stage-wrap")
        sw:tag("p"):tag("span"):addClass("card_content_feat-stage"):wikitext("STAGE")
        sw:tag("div"):addClass("card_content_feat-number")
            :wikitext(string.format("%02d", stage.stage or 0))
        local rd = row:tag("div")
        if stage.value and type(stage.value) == "table" then
            for _, v in ipairs(stage.value) do
                if v.description then
                    local desc = rd:tag("span"):addClass("card_accent")
                    addEffectTemplateText(desc, v.description)
                    rd:tag("br")
                end
            end
        end
        if stage.stat_boosts and type(stage.stat_boosts) == "table" then
            local boostTexts = {}
            for _, boost in ipairs(stage.stat_boosts) do
                local typeText = boost.type or ""
                local valueText = boost.value or ""
                if typeText ~= "" or valueText ~= "" then
                    table.insert(boostTexts, typeText .. "提升" .. valueText)
                end
            end
            if #boostTexts > 0 then
                rd:wikitext(table.concat(boostTexts, ","))
            end
        end
        if stage.extra_name then
            rd:tag("span"):addClass("card_content_feat-stage-cn")
                :wikitext("「" .. stage.extra_name .. "」")
        end
        if stage.extra_description then
            local extraDesc = rd:tag("span"):addClass("card_content_feat-extra-desc")
            addEffectTemplateText(extraDesc, stage.extra_description)
        end
    end
    return ul
end

local function cleanText(value)
    if value == nil then return "" end
    return mw.text.trim(tostring(value))
end

local function rewardTypeLabel(rewardType)
    local labels = {
        label = "标签",
        avatar_frame = "头像框",
        title = "称号",
        namecard = "名片",
        chat_bubble = "聊天气泡",
        cosmetic = "装扮",
        other = "奖励",
    }
    return labels[cleanText(rewardType)] or "奖励"
end

local function rewardTypeClass(rewardType)
    local key = cleanText(rewardType)
    if key == "label" or key == "avatar_frame" or key == "title"
        or key == "namecard" or key == "chat_bubble" or key == "cosmetic" then
        return "card_content_reward--" .. key
    end
    return "card_content_reward--other"
end

local function rewardFileName(fileName)
    local name = cleanText(fileName)
    if name == "" then return "" end
    name = name:gsub("^文件:", ""):gsub("^File:", "")
    return name
end

local function defaultRewardFile(itemType, imageKey, mode)
    local key = cleanText(imageKey)
    if key == "" then return "" end

    local itemIconTypes = {
        label = true,
        avatar_frame = true,
        namecard = true,
    }

    if mode == "icon" and itemIconTypes[cleanText(itemType)] then
        return "道具_" .. key .. ".png"
    end

    local prefixes = {
        title = { display = "称号_", icon = "道具_" },
        chat_bubble = { display = "聊天气泡_", icon = "道具_" },
        cosmetic = { display = "装扮_", icon = "道具_" },
        other = { display = "奖励_", icon = "道具_" },
    }

    local selected = prefixes[cleanText(itemType)] or prefixes.other
    local prefix = selected[mode] or selected.display
    return prefix .. key .. ".png"
end

local function firstText(...)
    for i = 1, select("#", ...) do
        local text = cleanText(select(i, ...))
        if text ~= "" then return text end
    end
    return ""
end

local function acquisitionLabel(acquisitionType)
    local key = cleanText(acquisitionType)
    if key == "limited" or key == "限定" then return "限定" end
    if key == "permanent" or key == "常驻" then return "常驻" end
    return key
end

local function isLimitedAcquisition(acquisitionType)
    local key = cleanText(acquisitionType)
    return key == "limited" or key == "限定"
end

local function formatReleaseDate(value)
    local text = cleanText(value)
    if text == "" then return "" end
    text = text:gsub("T.*$", "")
    text = text:gsub("-", ".")
    return text
end

local function makeAvailabilitySection(card)
    if not card or type(card) ~= "table" then return nil end
    local poolData = type(card.summon_pool) == "table" and card.summon_pool or {}
    local acquisition = acquisitionLabel(card.acquisition_type)
    local releaseDate = formatReleaseDate(card.release_date)
    local poolName = firstText(poolData.name, poolData.pool_id, card.summon_pool_name, card.summon_pool_id)
    local poolFile = rewardFileName(firstText(poolData.title_wiki_file, card.summon_pool_title_wiki_file))
    local limited = isLimitedAcquisition(card.acquisition_type)

    if acquisition == "" and releaseDate == "" and poolName == "" and poolFile == "" then
        return nil
    end

    local node = mw.html.create("div"):addClass("card_content_availability")
    if limited then
        node:addClass("card_content_availability--limited")
    else
        node:addClass("card_content_availability--permanent")
    end

    if limited and poolFile ~= "" then
        node:tag("div"):addClass("card_content_availability-pool-image")
            :wikitext("[[文件:" .. poolFile .. "|280px|link=]]")
    elseif limited and poolName ~= "" then
        node:tag("div"):addClass("card_content_availability-pool-name")
            :wikitext(poolName)
    end

    local top = node:tag("div"):addClass("card_content_availability-top")
    if limited then
        top:tag("span"):addClass("card_content_availability-badge")
            :wikitext(acquisition ~= "" and acquisition or "限定")
    end
    if releaseDate ~= "" then
        local dateNode = top:tag("span"):addClass("card_content_availability-date")
        dateNode:tag("span"):addClass("card_content_availability-date-label"):wikitext("上线日期")
        dateNode:tag("span"):addClass("card_content_availability-date-value"):wikitext(releaseDate)
    end

    if (not limited) and poolName ~= "" then
        node:tag("div"):addClass("card_content_availability-pool-name")
            :wikitext("卡池 " .. poolName)
    end

    return node
end

local function rewardItemData(reward)
    if reward and type(reward.cosmetic_item) == "table" then return reward.cosmetic_item end
    if reward and type(reward.item) == "table" then return reward.item end
    return {}
end

local function rewardUsesItemIcon(itemType)
    local key = cleanText(itemType)
    return key == "label" or key == "avatar_frame" or key == "namecard"
end

local function rewardSortPriority(reward)
    local itemData = rewardItemData(reward)
    local itemType = firstText(itemData.item_type, reward and reward.reward_type, "other")
    local priority = {
        label = 1,
        avatar_frame = 2,
        namecard = 3,
    }
    return priority[itemType] or 50
end

local function sortedRewardRows(rewards)
    local rows = {}
    for idx, reward in ipairs(rewards) do
        table.insert(rows, { reward = reward, index = idx })
    end
    table.sort(rows, function(a, b)
        local ap = rewardSortPriority(a.reward)
        local bp = rewardSortPriority(b.reward)
        if ap ~= bp then return ap < bp end

        local as = tonumber(a.reward and a.reward.sort) or a.index
        local bs = tonumber(b.reward and b.reward.sort) or b.index
        if as ~= bs then return as < bs end

        return a.index < b.index
    end)
    return rows
end

local function makeRewardsSection(rewards)
    if not rewards or type(rewards) ~= "table" or #rewards == 0 then return nil end

    local section = mw.html.create("div"):addClass("card_content_rewards-section")
    section:node(makeTitle("奖励", "Rewards"))
    section:node(makeHr())
    local list = section:tag("div"):addClass("card_content_rewards")

    local count = 0
    for _, rewardRow in ipairs(sortedRewardRows(rewards)) do
        local reward = rewardRow.reward
        if reward and type(reward) == "table" then
            local itemData = rewardItemData(reward)
            local itemType = firstText(itemData.item_type, reward.reward_type, "other")
            local name = firstText(itemData.name, reward.name)
            local imageKey = firstText(itemData.image_key, name)
            local description = firstText(itemData.item_description, reward.description)
            local obtainNote = cleanText(reward.obtain_note)
            local typeLabel = rewardTypeLabel(itemType)
            local useItemIcon = rewardUsesItemIcon(itemType)
            local displayFile = ""
            if not useItemIcon then
                displayFile = firstText(
                    rewardFileName(itemData.display_wiki_file),
                    rewardFileName(reward.display_wiki_file),
                    defaultRewardFile(itemType, imageKey, "display")
                )
            end
            local iconFile = firstText(
                defaultRewardFile(itemType, imageKey, "icon"),
                rewardFileName(itemData.icon_wiki_file),
                rewardFileName(reward.icon_wiki_file)
            )

            if name ~= "" or description ~= "" or displayFile ~= "" or iconFile ~= "" then
                count = count + 1
                local rewardNode = list:tag("div")
                    :addClass("card_content_reward")
                    :addClass(rewardTypeClass(itemType))

                local header = rewardNode:tag("div"):addClass("card_content_reward-header")
                if displayFile ~= "" then
                    header:tag("div"):addClass("card_content_reward-display")
                        :attr("role", "button")
                        :attr("tabindex", "0")
                        :attr("aria-label", "查看奖励图片")
                        :attr("data-card-lightbox-image", "1")
                        :wikitext("[[文件:" .. displayFile .. "|360x160px|link=]]")
                elseif iconFile ~= "" then
                    header:tag("div"):addClass("card_content_reward-icon")
                        :attr("role", "button")
                        :attr("tabindex", "0")
                        :attr("aria-label", "查看奖励图片")
                        :attr("data-card-lightbox-image", "1")
                        :wikitext("[[文件:" .. iconFile .. "|72x72px|link=]]")
                else
                    header:tag("div"):addClass("card_content_reward-visual card_content_reward-visual--empty")
                        :wikitext(typeLabel)
                end

                local heading = header:tag("div"):addClass("card_content_reward-heading")
                local titleRow = heading:tag("div"):addClass("card_content_reward-title-row")
                titleRow:tag("span"):addClass("card_content_reward-name"):wikitext(val(name))
                titleRow:tag("span"):addClass("card_content_reward-type"):wikitext(typeLabel)
                if obtainNote ~= "" then
                    heading:tag("div"):addClass("card_content_reward-obtain"):wikitext(obtainNote)
                end
                if description ~= "" then
                    rewardNode:tag("div"):addClass("card_content_reward-description"):wikitext(description)
                end
            end
        end
    end

    if count == 0 then return nil end
    return section
end

local function makeStoryTimeline(stories, cardTitle)
    local timeline = mw.html.create("div")
        :addClass("card_timeline card_content_story-timeline")

    timeline:tag("div"):addClass("card_timeline-line")

    if not stories or type(stories) ~= "table" or #stories == 0 then
        timeline:tag("div"):addClass("card_timeline-item"):wikitext("(小传数据待补充)")
        return timeline
    end

    local romans       = { "Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ", "Ⅴ" }
    local unlockLevels = { "Lv.1 解锁", "Lv.10 解锁", "Lv.30 解锁", "Lv.50 解锁", "Lv.70 解锁" }

    for idx, chapter in ipairs(stories) do
        local item = timeline:tag("div"):addClass("card_timeline-item")
        if idx == #stories then item:addClass("card_timeline-item--last") end
        item:tag("div"):addClass("card_timeline-dot")
        item:tag("div"):addClass("card_timeline-unlock")
            :wikitext(unlockLevels[idx] or ("Lv." .. (idx * 20) .. " 解锁"))
        local titleDiv = item:tag("div"):addClass("card_timeline-title")
        titleDiv:tag("span"):addClass("card_timeline-numeral"):wikitext(romans[idx] or tostring(idx))
        titleDiv:tag("span"):addClass("card_timeline-chapter"):wikitext(cardTitle or "")
        titleDiv:tag("span"):addClass("card_timeline-label"):wikitext("/ Story")
        local bodyDiv = item:tag("div"):addClass("card_timeline-body")
        local text = chapter.text or ""
        if text ~= "" then
            text = text:gsub("\r\n", "\n")
            local paragraphs = mw.text.split(text, "\n+")
            for _, para in ipairs(paragraphs) do
                para = mw.text.trim(para)
                if para ~= "" then bodyDiv:tag("p"):wikitext(para) end
            end
        end
    end

    return timeline
end

function p.render(frame)
    local name = mw.text.trim(frame.args[1] or frame.args.name or "")
    if name == "" then
        return '<span class="error">错误:请提供卡片名称,例如 {{#invoke:CardData|render|复仇童谣}}</span>'
    end

    local encodedName = mw.uri.encode(name, "QUERY")
    local apiUrl = "https://data.saltedkiss.org/items/cards"
        .. "?fields=source_style_id,stylename,rarity,tags,acquisition_type,release_date,summon_pool_id,summon_pool_name,summon_pool_title_wiki_file,summon_pool.pool_id,summon_pool.name,summon_pool.title_wiki_file,character.name,profession.name,desire.name"
        .. ",collection_type,collection_refine_pool"
        .. ",collection_fixed_attributes.status,collection_fixed_attributes.sort"
        .. ",collection_fixed_attributes.source_style_id,collection_fixed_attributes.style_name"
        .. ",collection_fixed_attributes.position,collection_fixed_attributes.slot_index"
        .. ",collection_fixed_attributes.attr_id,collection_fixed_attributes.attr_name"
        .. ",collection_fixed_attributes.attr_symbol,collection_fixed_attributes.source_raw"
        .. ",collection_refine_pools.status,collection_refine_pools.sort"
        .. ",collection_refine_pools.pool_id,collection_refine_pools.name"
        .. ",collection_refine_pools.source_style_id,collection_refine_pools.style_name"
        .. ",collection_refine_pools.collection_type"
        .. ",collection_refine_pools.terms.status,collection_refine_pools.terms.sort"
        .. ",collection_refine_pools.terms.pool_id,collection_refine_pools.terms.effect_id"
        .. ",collection_refine_pools.terms.name,collection_refine_pools.terms.effect_type"
        .. ",collection_refine_pools.terms.effect_value,collection_refine_pools.terms.attr_id"
        .. ",collection_refine_pools.terms.attr_value,collection_refine_pools.terms.buff_prefix"
        .. ",collection_refine_pools.terms.description,collection_refine_pools.terms.quality"
        .. ",collection_refine_pools.terms.is_exclusive_red,collection_refine_pools.terms.first_weight"
        .. ",collection_refine_pools.terms.normal_weight,collection_refine_pools.terms.guarantee_count"
        .. ",intel,supply,execute,strategy"
        .. ",attribute_growths.status,attribute_growths.sort"
        .. ",attribute_growths.source_style_id,attribute_growths.source_hero_card_id"
        .. ",attribute_growths.attr_rank,attribute_growths.base_hp"
        .. ",attribute_growths.base_atk,attribute_growths.base_def"
        .. ",attribute_growths.hp_rate,attribute_growths.atk_rate,attribute_growths.def_rate"
        .. ",attribute_growths.break_hp,attribute_growths.break_atk,attribute_growths.break_def"
        .. ",attribute_growths.formula_version"
        .. ",skill_normal_attack.name,skill_normal_attack.type,skill_normal_attack.weapon"
        .. ",skill_normal_attack.description,skill_normal_attack.description_template"
        .. ",skill_normal_attack.levels.*"
        .. ",weapon_attack_profiles.status,weapon_attack_profiles.source_style_id"
        .. ",weapon_attack_profiles.normal_skill_id,weapon_attack_profiles.normal_skill_name"
        .. ",weapon_attack_profiles.weapon_type,weapon_attack_profiles.attack_range,weapon_attack_profiles.avg_ms_per_hit"
        .. ",weapon_attack_profiles.hit_events_per_sec,weapon_attack_profiles.action_cycles_per_sec"
        .. ",weapon_attack_profiles.real_attack_cycle_ms,weapon_attack_profiles.hit_event_count"
        .. ",weapon_attack_profiles.reload_count,weapon_attack_profiles.reload_time_ms"
        .. ",weapon_attack_profiles.fire_frames,weapon_attack_profiles.verification_status"
        .. ",weapon_attack_profiles.aim_target_rule,weapon_attack_profiles.aim_time_ms"
        .. ",weapon_attack_profiles.base_atk_spd_for_aim,weapon_attack_profiles.real_aim_time_baseline_ms"
        .. ",weapon_attack_profiles.real_aim_time_formula,weapon_attack_profiles.normal_action_ms_raw"
        .. ",weapon_attack_profiles.normal_action_ms_tick,weapon_attack_profiles.normal_action_tick_formula"
        .. ",weapon_attack_profiles.normal_hit_event_count,weapon_attack_profiles.reload_per_action_ms"
        .. ",weapon_attack_profiles.sustained_cycle_ms_no_aim,weapon_attack_profiles.sustained_actions_per_sec_no_aim"
        .. ",weapon_attack_profiles.sustained_hit_events_per_sec_no_aim,weapon_attack_profiles.first_magazine_cycle_ms_with_aim"
        .. ",weapon_attack_profiles.first_magazine_actions_per_sec_with_aim,weapon_attack_profiles.first_magazine_hit_events_per_sec_with_aim"
        .. ",weapon_attack_profiles.speed_formula_version,weapon_attack_profiles.speed_formula_summary"
        .. ",weapon_attack_profiles.video_observed_hits_per_sec,weapon_attack_profiles.verification_note"
        .. ",weapon_attack_profiles.template.template_name,weapon_attack_profiles.template.weapon_type"
        .. ",weapon_attack_profiles.template.avg_ms_per_hit,weapon_attack_profiles.template.hit_events_per_sec"
        .. ",weapon_attack_profiles.template.real_attack_cycle_ms,weapon_attack_profiles.template.attack_range,weapon_attack_profiles.template.hit_count"
        .. ",weapon_attack_profiles.template.reload_count,weapon_attack_profiles.template.reload_time_ms"
        .. ",weapon_attack_profiles.template.fire_frames_pattern"
        .. ",skill_passive.name,skill_passive.type,skill_passive.trigger_type"
        .. ",skill_passive.trigger_value,skill_passive.tags,skill_passive.description"
        .. ",skill_passive.description_template,skill_passive.levels.*"
        .. ",skill_ultimate.name,skill_ultimate.type,skill_ultimate.desire_cost"
        .. ",skill_ultimate.tags,skill_ultimate.description"
        .. ",skill_ultimate.description_template,skill_ultimate.levels.*"
        .. ",feats.stages"
        .. ",rewards.status,rewards.sort,rewards.reward_type,rewards.name"
        .. ",rewards.description,rewards.obtain_note,rewards.display_wiki_file"
        .. ",rewards.icon_wiki_file,rewards.source_item_id"
        .. ",rewards.cosmetic_item.status,rewards.cosmetic_item.item_type"
        .. ",rewards.cosmetic_item.name,rewards.cosmetic_item.usage_description"
        .. ",rewards.cosmetic_item.item_description,rewards.cosmetic_item.image_key"
        .. ",rewards.cosmetic_item.display_wiki_file,rewards.cosmetic_item.icon_wiki_file"
        .. ",rewards.cosmetic_item.source_item_id"
        .. ",rewards.item.status,rewards.item.item_type,rewards.item.name"
        .. ",rewards.item.usage_description,rewards.item.item_description"
        .. ",rewards.item.image_key,rewards.item.display_wiki_file"
        .. ",rewards.item.icon_wiki_file,rewards.item.source_item_id"
        .. ",stories.text"
        .. "&filter[stylename][_eq]=" .. encodedName
        .. "&deep[rewards][_filter][status][_eq]=published"
        .. "&deep[rewards][_sort]=sort"
        .. "&deep[collection_fixed_attributes][_filter][status][_eq]=published"
        .. "&deep[collection_fixed_attributes][_sort]=position,slot_index"
        .. "&deep[collection_refine_pools][_filter][status][_eq]=published"
        .. "&deep[collection_refine_pools][_sort]=sort"
        .. "&deep[collection_refine_pools][terms][_filter][status][_eq]=published"
        .. "&deep[collection_refine_pools][terms][_sort]=effect_id"
        .. "&deep[attribute_growths][_filter][status][_eq]=published"
        .. "&deep[attribute_growths][_sort]=sort"
        .. "&deep[weapon_attack_profiles][_filter][status][_eq]=published"
        .. "&deep[weapon_attack_profiles][_sort]=sort"

    local rawResponse = frame:preprocess(
        '{{#get_web_data:url=' .. apiUrl
        .. '|format=text|data=responseText=__text}}'
        .. '{{#external_value:responseText}}'
    )
    rawResponse = mw.text.trim(rawResponse or "")

    local parsed = parseJson(rawResponse)
    local card   = parsed and type(parsed.data) == "table" and parsed.data[1] or nil

    if not card then
        return '<span class="error">⚠ 无法加载卡片数据:' .. mw.text.nowiki(name)
            .. '(response=' .. mw.text.nowiki(rawResponse) .. ')</span>'
    end

    local charData   = card.character or {}
    local profData   = card.profession or {}
    local desireData = card.desire or {}
    local skillNA    = card.skill_normal_attack or {}
    local skillPA    = card.skill_passive or {}
    local skillUL    = card.skill_ultimate or {}
    local stories    = card.stories or {}

    local featsStagesJson = "[]"
    if card.feats and type(card.feats) == "table" and card.feats[1] then
        local sr = card.feats[1].stages
        featsStagesJson = type(sr) == "table" and mw.text.jsonEncode(sr)
                       or type(sr) == "string" and sr or "[]"
    end

    local function levelsToJson(skill)
        if not skill.levels then return "[]" end
        if type(skill.levels) == "string" then return skill.levels end
        if type(skill.levels) == "table"  then return mw.text.jsonEncode(skill.levels) end
        return "[]"
    end
    skillNA.levels = levelsToJson(skillNA)
    skillPA.levels = levelsToJson(skillPA)
    skillUL.levels = levelsToJson(skillUL)

    local function tagsToJson(skill)
        if not skill.tags then return "[]" end
        if type(skill.tags) == "string" then return skill.tags end
        if type(skill.tags) == "table"  then return mw.text.jsonEncode(skill.tags) end
        return "[]"
    end
    skillPA.tags = tagsToJson(skillPA)
    skillUL.tags = tagsToJson(skillUL)

    local weaponAttackProfile = fetchWeaponAttackProfile(frame, card, skillNA)
    local weaponAttackPayload = WeaponAttack.payload(weaponAttackProfile)

    local rarity    = tonumber(card.rarity) or 0
    local stylename = val(card.stylename)
    local charName  = val(charData.name)
    local profName  = val(profData.name)
    local desName   = val(desireData.name)

    local root = mw.html.create("div")
        :addClass("ron-card ron-card--rarity-" .. rarity)
        :attr("data-rarity", rarity)

    -- 全屏背景图,文件命名规则:卡面_卡面名.png
    -- rarity 6 默认展示第二卡面:卡面_卡面名_2.png
    local cardFaceFile1 = "卡面_" .. stylename .. ".png"
    local cardFaceFile2 = "卡面_" .. stylename .. "_2.png"

    local fullscreen = root:tag("div"):addClass("card_fullscreen-img")
    if rarity == 6 then
        fullscreen:addClass("card_fullscreen-img--switchable")
            :attr("data-active-face", "2")
        fullscreen:tag("div")
            :addClass("card_face card_face--primary")
            :attr("data-card-face", "1")
            :wikitext("[[文件:" .. cardFaceFile1 .. "]]")
        fullscreen:tag("div")
            :addClass("card_face card_face--secondary is-active")
            :attr("data-card-face", "2")
            :wikitext("[[文件:" .. cardFaceFile2 .. "]]")
    else
        fullscreen:wikitext("[[文件:" .. cardFaceFile1 .. "]]")
    end

    local hero = root:tag("div"):addClass("card_hero"):css("height", "100vh")
    if rarity == 6 then
        local switch = hero:tag("div")
            :addClass("card_face-switch")
            :attr("role", "group")
            :attr("aria-label", "切换卡面")
        switch:tag("div")
            :addClass("card_face-switch-btn")
            :attr("role", "button")
            :attr("tabindex", "0")
            :attr("data-card-face", "1")
            :wikitext("Ⅰ")
        switch:tag("div")
            :addClass("card_face-switch-btn is-active")
            :attr("role", "button")
            :attr("tabindex", "0")
            :attr("data-card-face", "2")
            :wikitext("Ⅱ")
    end

    local layout = root:tag("div"):addClass("card_content-container")
        :tag("div"):addClass("card_content-background")
        :tag("div"):addClass("card_content-inner")
        :tag("div"):addClass("card_content card_content--layout")

    -- 左栏
    local leftCol = layout:tag("div"):addClass("card_content_left")

    -- 卡名
    local nc = leftCol:tag("div"):addClass("card_content_name-container")
    local nr = nc:tag("div"):addClass("card_content_name-row")
    nr:node(makeNameIcon(profName))
    nr:tag("div"):addClass("card_content_name-classtext"):wikitext(profName)
    nr:node(makeNameIcon(desName, "card_content_name-icon--ml", "38px"))
    nr:tag("div"):addClass("card_content_name-classtext"):wikitext(desName)
    local nt = nc:tag("div"):addClass("card_content_name-text")
    nt:wikitext(stylename .. " ")
    nt:tag("span"):addClass("card_accent"):wikitext("·")
    nt:wikitext(" " .. charName)
    local styleTags = makeStyleTagsRow(card.tags)
    if styleTags then nc:node(styleTags) end
    local availability = makeAvailabilitySection(card)
    if availability then nc:node(availability) end

    -- 信息(稀有度 + 四维)
    leftCol:node(makeTitle("信息", "Info"))
    leftCol:node(makeHr())
    leftCol:node(makeInfoSection(card.intel, card.supply, card.execute, card.strategy, rarity))

    -- 等级属性
    leftCol:node(makeStyleAttributeGrowthSection(card))

    -- 映像
    leftCol:node(makeStyleCollectionSection(card))

    local rewardsSection = makeRewardsSection(card.rewards)
    if rewardsSection then
        leftCol:node(rewardsSection)
    end

    -- 右栏
    local rightCol = layout:tag("div"):addClass("card_content_right")
    rightCol:node(makeTitle("战斗技能", "Tactical"))
    rightCol:node(makeHr("card_content-item-hr--mb12"))
    rightCol:node(makeSkillCard(skillNA, "normal_attack", stylename, weaponAttackPayload))
    rightCol:tag("div"):addClass("card_content_skill-gap")
    rightCol:node(makeSkillCard(skillPA, "passive", stylename))
    rightCol:tag("div"):addClass("card_content_skill-gap")
    rightCol:node(makeSkillCard(skillUL, "ultimate", stylename))

    rightCol:node(makeTitle("觉醒", "Feat"))
    rightCol:node(makeHr("card_content-item-hr--mb12"))
    rightCol:node(makeFeatList(featsStagesJson))

    -- 小传
    local storySection = rightCol:tag("div"):addClass("card_content_story")
    local storyHeader  = storySection:tag("div"):addClass("card_content_story-header")
    local storyLeft    = storyHeader:tag("div"):addClass("card_content_story-header-left")
    storyLeft:node(makeTitle("小传", "Story"))
    storyLeft:node(makeHr("card_content-item-hr--mb12"))
    storyLeft:tag("div"):addClass("card_content_story-toggle")
        :attr("data-collapsed", "false")
        :tag("span"):addClass("story-toggle-text"):wikitext("收起"):done()
        :tag("span"):addClass("story-toggle-icon"):wikitext("-")
    storyHeader:tag("div"):addClass("card_content_story-header-spacer")
    storyHeader:node(makeStoryTimeline(stories, stylename))

    -- 邀约
    rightCol:tag("div"):css("height", "12px"):css("width", "100%")
    rightCol:node(makeTitle("邀约", "Date"))
    rightCol:node(makeHr("card_content-item-hr--mb12"))
    rightCol:tag("div"):addClass("card_content_date")
        :tag("div"):addClass("card_content_date-track")
        :tag("ul"):addClass("card_content_date-list")
        :tag("li"):addClass("card_content_date-item"):wikitext("(邀约数据待补充)")

    return tostring(root)
end

return p