Jump to content

Module:GearSet: Difference between revisions

From EverQuest Legends Wiki
No edit summary
No edit summary
Line 565: Line 565:


local function extractDropMobs(dropsfrom)
local function extractDropMobs(dropsfrom)
     local drops =
     local drops = tostring(dropsfrom or '')
        tostring(dropsfrom or '')


     drops = drops:gsub('\r\n', '\n')
     drops = drops:gsub('\r\n', '\n')
Line 572: Line 571:
     drops = drops:gsub('<!%-%-.-%-%->', '')
     drops = drops:gsub('<!%-%-.-%-%->', '')


     local results = {}
     local allDrops = {}
     local seen = {}
     local seen = {}


     -- Only bullet-list entries are treated as mobs.
     -- Collect unique bullet-list entries only.
    -- Zone headings remain excluded.
     for line in drops:gmatch('[^\n]+') do
     for line in drops:gmatch('[^\n]+') do
         local mob =
         local mob = line:match(
            line:match(
            '^%s*%*+%s*(.-)%s*$'
                '^%s*%*+%s*(.-)%s*$'
        )
            )


         if mob
         if mob
Line 588: Line 585:
         then
         then
             seen[mob] = true
             seen[mob] = true
            table.insert(allDrops, mob)
        end
    end
    local displayedDrops = {}


             table.insert(results, mob)
    -- Show no more than the first two entries.
         end
    for index = 1, math.min(2, #allDrops) do
        table.insert(
             displayedDrops,
            allDrops[index]
        )
    end
 
    -- A third line indicates additional drop sources.
    if #allDrops > 2 then
        table.insert(
            displayedDrops,
            "''and more''"
         )
     end
     end


     return table.concat(results, '<br>')
     return table.concat(
        displayedDrops,
        '<br>'
    )
end
end
------------------------------------------------------------------------
------------------------------------------------------------------------
-- Item loading and hover-link rendering
-- Item loading and hover-link rendering

Revision as of 01:43, 14 July 2026

Documentation for this module may be created at Module:GearSet/doc

local p = {}

------------------------------------------------------------------------
-- Table columns
------------------------------------------------------------------------

local NUMERIC_COLUMNS = {
    {
        key = 'ac',
        header = 'AC',
        labels = { 'AC' },
    },
    {
        key = 'hp',
        header = 'HP',
        labels = { 'HP' },
    },
    {
        key = 'mana',
        header = 'MANA',
        labels = { 'MANA' },
    },
    {
        key = 'endurance',
        header = 'END',
        labels = {
            'ENDURANCE',
            'ENDUR',
            'END',
        },
    },

    {
        key = 'str',
        header = 'STR',
        labels = { 'STR' },
    },
    {
        key = 'sta',
        header = 'STA',
        labels = { 'STA' },
    },
    {
        key = 'int',
        header = 'INT',
        labels = { 'INT' },
    },
    {
        key = 'wis',
        header = 'WIS',
        labels = { 'WIS' },
    },
    {
        key = 'agi',
        header = 'AGI',
        labels = { 'AGI' },
    },
    {
        key = 'dex',
        header = 'DEX',
        labels = { 'DEX' },
    },
    {
        key = 'cha',
        header = 'CHA',
        labels = { 'CHA' },
    },

    {
        key = 'mr',
        header = 'MR',
        labels = {
            'SV MAGIC',
            'SV MAG',
            'MR',
        },
    },
    {
        key = 'fr',
        header = 'FR',
        labels = {
            'SV FIRE',
            'FR',
        },
    },
    {
        key = 'cr',
        header = 'CR',
        labels = {
            'SV COLD',
            'CR',
        },
    },
    {
        key = 'dr',
        header = 'DR',
        labels = {
            'SV DISEASE',
            'SV DIS',
            'DR',
        },
    },
    {
        key = 'pr',
        header = 'PR',
        labels = {
            'SV POISON',
            'SV POI',
            'PR',
        },
    },
    {
        key = 'vr',
        header = 'VR',
        labels = {
            'SV VR',
            'VR',
        },
    },
}

-- Item, Slot, numeric columns, Weight, Effect, Dropped By.
local TOTAL_COLUMN_COUNT =
    2 +
    #NUMERIC_COLUMNS +
    3

------------------------------------------------------------------------
-- General helpers
------------------------------------------------------------------------

local function trim(value)
    return mw.text.trim(tostring(value or ''))
end

local function escapePattern(value)
    return (
        tostring(value or ''):gsub(
            '([%^%$%(%)%%%.%[%]%*%+%-%?])',
            '%%%1'
        )
    )
end

local function formatPlainNumber(value)
    if value == nil then
        return ''
    end

    if math.floor(value) == value then
        return tostring(math.floor(value))
    end

    local formatted = string.format('%.2f', value)

    formatted = formatted:gsub('0+$', '')
    formatted = formatted:gsub('%.$', '')

    return formatted
end

local function formatWeight(value)
    if value == nil then
        return ''
    end

    local formatted = string.format('%.2f', value)

    formatted = formatted:gsub('0+$', '')
    formatted = formatted:gsub('%.$', '')

    -- Preserve at least one decimal place for whole-number weights.
    -- The plain-string search must look for "." rather than "%.".
    if not formatted:find('.', 1, true) then
        formatted = formatted .. '.0'
    end

    return formatted
end

------------------------------------------------------------------------
-- Itempage template parser
------------------------------------------------------------------------

-- Reads the parameters inside the first {{Itempage ...}} invocation.
-- Nested templates, links, and wiki tables are accounted for so their
-- internal pipes are not mistaken for Itempage parameter separators.
local function parseItempageParameters(content)
    local startPosition =
        content:find('{{%s*[Ii]tempage[%s|]')

    if not startPosition then
        return nil
    end

    local segments = {}
    local buffer = {}

    local curlyDepth = 1
    local squareDepth = 0
    local tableDepth = 0

    local position = startPosition + 2
    local contentLength = #content

    while position <= contentLength do
        local pair =
            content:sub(position, position + 1)

        local character =
            content:sub(position, position)

        if pair == '{{' then
            curlyDepth = curlyDepth + 1

            table.insert(buffer, pair)

            position = position + 2

        elseif pair == '}}' then
            if curlyDepth == 1 then
                table.insert(
                    segments,
                    table.concat(buffer)
                )

                break
            end

            curlyDepth = curlyDepth - 1

            table.insert(buffer, pair)

            position = position + 2

        elseif pair == '[[' then
            squareDepth = squareDepth + 1

            table.insert(buffer, pair)

            position = position + 2

        elseif pair == ']]' and squareDepth > 0 then
            squareDepth = squareDepth - 1

            table.insert(buffer, pair)

            position = position + 2

        elseif pair == '{|' then
            tableDepth = tableDepth + 1

            table.insert(buffer, pair)

            position = position + 2

        elseif pair == '|}' and tableDepth > 0 then
            tableDepth = tableDepth - 1

            table.insert(buffer, pair)

            position = position + 2

        elseif character == '|'
            and curlyDepth == 1
            and squareDepth == 0
            and tableDepth == 0
        then
            table.insert(
                segments,
                table.concat(buffer)
            )

            buffer = {}

            position = position + 1

        else
            table.insert(buffer, character)

            position = position + 1
        end
    end

    if #segments < 2 then
        return nil
    end

    local parameters = {}

    -- The first segment is the template name.
    for index = 2, #segments do
        local parameterName, parameterValue =
            segments[index]:match(
                '^%s*([%w_%-]+)%s*=(.*)$'
            )

        if parameterName then
            parameters[parameterName:lower()] =
                trim(parameterValue)
        end
    end

    return parameters
end

------------------------------------------------------------------------
-- Page loading
------------------------------------------------------------------------

local function getPageContent(pageName)
    local title = mw.title.new(pageName)

    -- Follow ordinary redirects.
    for _ = 1, 4 do
        if not title then
            return nil
        end

        local content = title:getContent()

        if not content then
            return nil
        end

        local redirectTarget =
            content:match(
                '^%s*#[Rr][Ee][Dd][Ii][Rr][Ee][Cc][Tt]' ..
                '%s*%[%[([^%]|#]+)'
            )

        if not redirectTarget then
            return content
        end

        title = mw.title.new(
            trim(redirectTarget)
        )
    end

    return nil
end

------------------------------------------------------------------------
-- Statsblock parsing
------------------------------------------------------------------------

local function normalizeStatsblock(statsblock)
    local stats = tostring(statsblock or '')

    stats = stats:gsub('\r\n', '\n')
    stats = stats:gsub('\r', '\n')

    -- Remove comments.
    stats = stats:gsub('<!%-%-.-%-%->', '')

    -- Convert every common form of <br> into a newline.
    stats = stats:gsub(
        '<[bB][rR]%s*/?%s*>',
        '\n'
    )

    stats = stats:gsub('&nbsp;', ' ')
    stats = stats:gsub('[ \t]+\n', '\n')

    return stats
end

local function extractNumericValue(
    statsblock,
    labels,
    allowDecimal
)
    local uppercaseStats =
        tostring(statsblock or ''):upper()

    local valuePattern

    if allowDecimal then
        valuePattern =
            '([%+%-]?%d+%.?%d*)'
    else
        valuePattern =
            '([%+%-]?%d+)'
    end

    for _, label in ipairs(labels) do
        local pattern =
            escapePattern(label:upper()) ..
            '%s*:%s*' ..
            valuePattern

        local value =
            uppercaseStats:match(pattern)

        if value then
            return tonumber(value)
        end
    end

    return nil
end

-- Returns all complete line values belonging to the supplied labels.
--
-- For:
-- Effect: [[Swift Spirit]] (Worn)
--
-- this returns:
-- [[Swift Spirit]] (Worn)
local function extractLabeledLines(
    statsblock,
    labels
)
    local results = {}
    local seen = {}

    for line in tostring(statsblock or ''):gmatch('[^\n]+') do
        local cleanLine = trim(line)
        local uppercaseLine = cleanLine:upper()

        for _, label in ipairs(labels) do
            local pattern =
                '^' ..
                escapePattern(label:upper()) ..
                '%s*:%s*'

            local _, valueStart =
                uppercaseLine:find(pattern)

            if valueStart then
                local value =
                    trim(
                        cleanLine:sub(
                            valueStart + 1
                        )
                    )

                if value ~= ''
                    and not seen[value]
                then
                    seen[value] = true

                    table.insert(
                        results,
                        value
                    )
                end

                break
            end
        end
    end

    return results
end

local function extractLine(statsblock, labels)
    local values =
        extractLabeledLines(
            statsblock,
            labels
        )

    return values[1] or ''
end

------------------------------------------------------------------------
-- Item-effect extraction
------------------------------------------------------------------------

local function buildEffect(
    parameters,
    statsblock
)
    local results = {}
    local seen = {}

    local function addEffect(value)
        value = trim(value)

        if value == ''
            or seen[value]
        then
            return
        end

        seen[value] = true

        table.insert(results, value)
    end

    --------------------------------------------------------------------
    -- Explicit effect lines
    --------------------------------------------------------------------

    local explicitEffects =
        extractLabeledLines(
            statsblock,
            {
                'Effect',
                'Click Effect',
                'Worn Effect',
                'Proc Effect',
                'Combat Effect',
            }
        )

    for _, effect in ipairs(explicitEffects) do
        addEffect(effect)
    end

    --------------------------------------------------------------------
    -- Focus effect stored as a separate Itempage parameter
    --------------------------------------------------------------------

    local focusEffect =
        trim(parameters.focus_effect)

    if focusEffect ~= '' then
        if focusEffect:find('%[%[') then
            addEffect(
                'Focus: ' .. focusEffect
            )
        else
            addEffect(
                'Focus: [[' ..
                focusEffect ..
                ']]'
            )
        end
    end

    --------------------------------------------------------------------
    -- Passive effects represented as statsblock lines
    --------------------------------------------------------------------

    local passiveEffectLabels = {
        'Haste',
        'HP Regen',
        'Mana Regen',
        'Endurance Regen',
    }

    for _, label in ipairs(passiveEffectLabels) do
        local values =
            extractLabeledLines(
                statsblock,
                { label }
            )

        for _, value in ipairs(values) do
            addEffect(
                label .. ': ' .. value
            )
        end
    end

    return table.concat(results, '<br>')
end

------------------------------------------------------------------------
-- Drop-source extraction
------------------------------------------------------------------------

local function extractDropMobs(dropsfrom)
    local drops = tostring(dropsfrom or '')

    drops = drops:gsub('\r\n', '\n')
    drops = drops:gsub('\r', '\n')
    drops = drops:gsub('<!%-%-.-%-%->', '')

    local allDrops = {}
    local seen = {}

    -- Collect unique bullet-list entries only.
    for line in drops:gmatch('[^\n]+') do
        local mob = line:match(
            '^%s*%*+%s*(.-)%s*$'
        )

        if mob
            and mob ~= ''
            and not seen[mob]
        then
            seen[mob] = true
            table.insert(allDrops, mob)
        end
    end

    local displayedDrops = {}

    -- Show no more than the first two entries.
    for index = 1, math.min(2, #allDrops) do
        table.insert(
            displayedDrops,
            allDrops[index]
        )
    end

    -- A third line indicates additional drop sources.
    if #allDrops > 2 then
        table.insert(
            displayedDrops,
            "''and more''"
        )
    end

    return table.concat(
        displayedDrops,
        '<br>'
    )
end
------------------------------------------------------------------------
-- Item loading and hover-link rendering
------------------------------------------------------------------------

local function createHoverLink(
    frame,
    pageName
)
    local success, rendered =
        pcall(
            function()
                return frame:preprocess(
                    '{{:' .. pageName .. '}}'
                )
            end
        )

    if success
        and trim(rendered) ~= ''
    then
        return rendered
    end

    return '[[' .. pageName .. ']]'
end

local function readItem(
    frame,
    pageName
)
    local content =
        getPageContent(pageName)

    if not content then
        return nil,
            'Item page does not exist or could not be read.'
    end

    local parameters =
        parseItempageParameters(content)

    if not parameters then
        return nil,
            'No Itempage template was found on the item page.'
    end

    local statsblock =
        normalizeStatsblock(
            parameters.statsblock
        )

    if statsblock == '' then
        return nil,
            'The item has no statsblock.'
    end

    local item = {
        pageName = pageName,

        itemName =
            trim(parameters.itemname) ~= ''
            and trim(parameters.itemname)
            or pageName,

        hover =
            createHoverLink(
                frame,
                pageName
            ),

        slot =
            extractLine(
                statsblock,
                { 'Slot' }
            ),

        weight =
            extractNumericValue(
                statsblock,
                {
                    'WT',
                    'Weight',
                },
                true
            ),

        effect =
            buildEffect(
                parameters,
                statsblock
            ),

        droppedBy =
            extractDropMobs(
                parameters.dropsfrom
            ),
    }

    for _, column in ipairs(NUMERIC_COLUMNS) do
        item[column.key] =
            extractNumericValue(
                statsblock,
                column.labels,
                false
            )
    end

    return item
end

------------------------------------------------------------------------
-- HTML helpers
------------------------------------------------------------------------

local function addCell(
    row,
    value,
    alignLeft
)
    local cell = row:tag('td')

    if alignLeft then
        cell:css(
            'text-align',
            'left'
        )
    end

    if value ~= nil
        and value ~= ''
    then
        cell:wikitext(
            tostring(value)
        )
    end

    return cell
end

local function addNumericCell(
    row,
    numericValue,
    displayedValue
)
    local cell = row:tag('td')

    if numericValue ~= nil then
        cell:attr(
            'data-sort-value',
            tostring(numericValue)
        )

        cell:wikitext(
            displayedValue
                or formatPlainNumber(
                    numericValue
                )
        )
    end

    return cell
end

local function addHeader(row, text)
    return row
        :tag('th')
        :attr('scope', 'col')
        :wikitext(text)
end

------------------------------------------------------------------------
-- Table rendering
------------------------------------------------------------------------

local function renderTable(
    frame,
    args,
    itemNames
)
    local output =
        mw.html.create('table')

    output
        :addClass('wikitable')
        :addClass('sortable')
        :addClass('eql-gear-set-table')
        :css('text-align', 'center')

    --------------------------------------------------------------------
    -- Caption
    --------------------------------------------------------------------

    local caption = trim(args.caption)

    if caption == '' then
        caption = trim(args.name)
    end

    if caption == '' then
        caption =
            mw.title.getCurrentTitle().text
    end

    output
        :tag('caption')
        :wikitext(caption)

    --------------------------------------------------------------------
    -- Column headings
    --------------------------------------------------------------------

    local headerRow = output:tag('tr')

    addHeader(headerRow, 'Item')
    addHeader(headerRow, 'Slot')

    for _, column in ipairs(NUMERIC_COLUMNS) do
        addHeader(
            headerRow,
            column.header
        )
    end

    addHeader(headerRow, 'Weight')
    addHeader(headerRow, 'Effect')
    addHeader(headerRow, 'Dropped By')

    --------------------------------------------------------------------
    -- Totals
    --------------------------------------------------------------------

    local totals = {}

    for _, column in ipairs(NUMERIC_COLUMNS) do
        totals[column.key] = 0
    end

    local totalWeight = 0

    --------------------------------------------------------------------
    -- Item rows
    --------------------------------------------------------------------

    for _, pageName in ipairs(itemNames) do
        local item, errorMessage =
            readItem(
                frame,
                pageName
            )

        if not item then
            local errorRow =
                output:tag('tr')

            errorRow
                :tag('td')
                :attr(
                    'colspan',
                    TOTAL_COLUMN_COUNT
                )
                :addClass('error')
                :css(
                    'text-align',
                    'left'
                )
                :wikitext(
                    '[[' ..
                    pageName ..
                    ']]: ' ..
                    errorMessage
                )
        else
            local row = output:tag('tr')

            addCell(
                row,
                item.hover,
                true
            )

            addCell(
                row,
                item.slot,
                true
            )

            for _, column in ipairs(NUMERIC_COLUMNS) do
                local value =
                    item[column.key]

                addNumericCell(
                    row,
                    value
                )

                if value ~= nil then
                    totals[column.key] =
                        totals[column.key] +
                        value
                end
            end

            addNumericCell(
                row,
                item.weight,
                formatWeight(item.weight)
            )

            if item.weight ~= nil then
                totalWeight =
                    totalWeight +
                    item.weight
            end

            addCell(
                row,
                item.effect,
                true
            )

            addCell(
                row,
                item.droppedBy,
                true
            )
        end
    end

    --------------------------------------------------------------------
    -- Totals row
    --------------------------------------------------------------------

    local totalRow = output:tag('tr')

    totalRow
        :addClass('sortbottom')
        :css('font-weight', 'bold')

    totalRow
        :tag('td')
        :attr('colspan', 2)
        :css('text-align', 'right')
        :wikitext('Totals')

    for _, column in ipairs(NUMERIC_COLUMNS) do
        addNumericCell(
            totalRow,
            totals[column.key]
        )
    end

    addNumericCell(
        totalRow,
        totalWeight,
        formatWeight(totalWeight)
    )

    addCell(totalRow, '', false)
    addCell(totalRow, '', false)

    return tostring(output)
end

------------------------------------------------------------------------
-- Automatic category metadata
------------------------------------------------------------------------

local function splitMetadata(value)
    local values = {}

    for part in tostring(value or ''):gmatch(
        '[^,;]+'
    ) do
        part = trim(part)

        if part ~= '' then
            table.insert(values, part)
        end
    end

    return values
end

local function addCategory(
    categories,
    seen,
    categoryName
)
    categoryName = trim(categoryName)

    if categoryName == ''
        or seen[categoryName]
    then
        return
    end

    seen[categoryName] = true

    table.insert(
        categories,
        '[[Category:' ..
        categoryName ..
        ']]'
    )
end

local function addGearSetCategories(
    categories,
    seen,
    value
)
    for _, entry in ipairs(
        splitMetadata(value)
    ) do
        addCategory(
            categories,
            seen,
            entry .. ' Gear Sets'
        )
    end
end

local function buildCategories(args)
    local categories = {}
    local seen = {}

    addCategory(
        categories,
        seen,
        'Gear Sets'
    )

    local setType =
        trim(args.type):lower()

    if setType == 'weapon'
        or setType == 'weapons'
    then
        addCategory(
            categories,
            seen,
            'Weapon Sets'
        )
    else
        addCategory(
            categories,
            seen,
            'Armor Sets'
        )
    end

    addGearSetCategories(
        categories,
        seen,
        args.era
    )

    addGearSetCategories(
        categories,
        seen,
        args.source
    )

    addGearSetCategories(
        categories,
        seen,
        args.armor
    )

    addGearSetCategories(
        categories,
        seen,
        args.class
    )

    addGearSetCategories(
        categories,
        seen,
        args.region
    )

    addGearSetCategories(
        categories,
        seen,
        args.craft
    )

    addGearSetCategories(
        categories,
        seen,
        args.series
    )

    local cultures =
        splitMetadata(args.culture)

    if #cultures > 0 then
        addCategory(
            categories,
            seen,
            'Cultural Gear Sets'
        )

        for _, culture in ipairs(cultures) do
            addCategory(
                categories,
                seen,
                culture ..
                ' Cultural Gear Sets'
            )
        end
    end

    -- Direct additional category names.
    for _, categoryName in ipairs(
        splitMetadata(args.categories)
    ) do
        addCategory(
            categories,
            seen,
            categoryName
        )
    end

    return table.concat(categories)
end

------------------------------------------------------------------------
-- Module entry point
------------------------------------------------------------------------

function p.main(frame)
    local parent = frame:getParent()

    local args =
        parent and parent.args
        or frame.args

    local indexedItems = {}

    for argumentName, argumentValue
        in pairs(args)
    do
        local numericIndex =
            tonumber(argumentName)

        local itemName =
            trim(argumentValue)

        if numericIndex
            and itemName ~= ''
        then
            table.insert(
                indexedItems,
                {
                    index = numericIndex,
                    name = itemName,
                }
            )
        end
    end

    table.sort(
        indexedItems,
        function(left, right)
            return left.index < right.index
        end
    )

    local itemNames = {}

    for _, item in ipairs(indexedItems) do
        table.insert(
            itemNames,
            item.name
        )
    end

    if #itemNames == 0 then
        return
            '<strong class="error">' ..
            'Gear Set requires at least one item page.' ..
            '</strong>'
    end

    return
        renderTable(
            frame,
            args,
            itemNames
        ) ..
        buildCategories(args)
end

return p