Module:GearSet: Difference between revisions
No edit summary |
No edit summary |
||
| Line 1: | Line 1: | ||
-- GearSet revision: conditional Effect and Dropped By columns | |||
local p = {} | local p = {} | ||
| Line 111: | Line 112: | ||
}, | }, | ||
} | } | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
| Line 808: | Line 803: | ||
itemNames | itemNames | ||
) | ) | ||
-------------------------------------------------------------------- | |||
-- Load items, inspect optional columns, and count wrist entries | |||
-------------------------------------------------------------------- | |||
local loadedItems = {} | |||
local suppliedWristCount = 0 | |||
local showEffectColumn = false | |||
local showDroppedByColumn = false | |||
for _, pageName in ipairs(itemNames) do | |||
local item, errorMessage = | |||
readItem( | |||
frame, | |||
pageName | |||
) | |||
if item then | |||
local isWrist = | |||
item.slot:upper():find( | |||
'WRIST', | |||
1, | |||
true | |||
) ~= nil | |||
if isWrist then | |||
suppliedWristCount = | |||
suppliedWristCount + 1 | |||
end | |||
if trim(item.effect) ~= '' then | |||
showEffectColumn = true | |||
end | |||
if trim(item.droppedBy) ~= '' then | |||
showDroppedByColumn = true | |||
end | |||
end | |||
table.insert( | |||
loadedItems, | |||
{ | |||
pageName = pageName, | |||
item = item, | |||
errorMessage = errorMessage, | |||
} | |||
) | |||
end | |||
-- Item, Slot, numeric columns, and Weight are always present. | |||
local totalColumnCount = | |||
2 + | |||
#NUMERIC_COLUMNS + | |||
1 | |||
if showEffectColumn then | |||
totalColumnCount = totalColumnCount + 1 | |||
end | |||
if showDroppedByColumn then | |||
totalColumnCount = totalColumnCount + 1 | |||
end | |||
-------------------------------------------------------------------- | |||
-- Table shell | |||
-------------------------------------------------------------------- | |||
local wrapper = | local wrapper = | ||
mw.html.create('div') | mw.html.create('div') | ||
| Line 866: | Line 927: | ||
addHeader(headerRow, 'Wt.', 'Weight') | addHeader(headerRow, 'Wt.', 'Weight') | ||
addHeader(headerRow, 'Effect', 'Item effects') | |||
addHeader(headerRow, 'Dropped By', 'Drop sources') | if showEffectColumn then | ||
addHeader(headerRow, 'Effect', 'Item effects') | |||
end | |||
if showDroppedByColumn then | |||
addHeader(headerRow, 'Dropped By', 'Drop sources') | |||
end | |||
-------------------------------------------------------------------- | -------------------------------------------------------------------- | ||
| Line 880: | Line 947: | ||
local totalWeight = 0 | local totalWeight = 0 | ||
-------------------------------------------------------------------- | -------------------------------------------------------------------- | ||
| Line 966: | Line 995: | ||
end | end | ||
addCell( | if showEffectColumn then | ||
addCell( | |||
row, | |||
item.effect, | |||
true | |||
) | |||
end | |||
addCell( | if showDroppedByColumn then | ||
addCell( | |||
row, | |||
item.droppedBy, | |||
true | |||
) | |||
end | |||
end | end | ||
| Line 1,001: | Line 1,034: | ||
:attr( | :attr( | ||
'colspan', | 'colspan', | ||
totalColumnCount | |||
) | ) | ||
:addClass('error') | :addClass('error') | ||
| Line 1,068: | Line 1,101: | ||
) | ) | ||
addCell(totalRow, '', false) | if showEffectColumn then | ||
addCell(totalRow, '', false) | addCell(totalRow, '', false) | ||
end | |||
if showDroppedByColumn then | |||
addCell(totalRow, '', false) | |||
end | |||
wrapper:node(output) | wrapper:node(output) | ||
Latest revision as of 16:04, 5 August 2026
Documentation for this module may be created at Module:GearSet/doc
-- GearSet revision: conditional Effect and Dropped By columns
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',
},
},
}
------------------------------------------------------------------------
-- 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
-- Keeps long slot names from unnecessarily widening the table.
-- This changes display text only; the item page itself is not modified.
local function formatSlot(value)
local slot = trim(value)
local uppercaseSlot = slot:upper()
if uppercaseSlot == 'SHOULDERS'
or uppercaseSlot == 'SHOULDER'
then
return 'SHOULD'
end
return slot
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(' ', ' ')
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, fullName)
local header = row
:tag('th')
:attr('scope', 'col')
:addClass('eql-gear-set-header')
:css('font-size', '0.72rem')
:css('line-height', '1.1')
:css('padding', '0.35em 0.4em')
:css('white-space', 'nowrap')
if fullName and fullName ~= '' then
header:attr('title', fullName)
end
return header:wikitext(text)
end
------------------------------------------------------------------------
-- Table rendering
------------------------------------------------------------------------
local function renderTable(
frame,
args,
itemNames
)
--------------------------------------------------------------------
-- Load items, inspect optional columns, and count wrist entries
--------------------------------------------------------------------
local loadedItems = {}
local suppliedWristCount = 0
local showEffectColumn = false
local showDroppedByColumn = false
for _, pageName in ipairs(itemNames) do
local item, errorMessage =
readItem(
frame,
pageName
)
if item then
local isWrist =
item.slot:upper():find(
'WRIST',
1,
true
) ~= nil
if isWrist then
suppliedWristCount =
suppliedWristCount + 1
end
if trim(item.effect) ~= '' then
showEffectColumn = true
end
if trim(item.droppedBy) ~= '' then
showDroppedByColumn = true
end
end
table.insert(
loadedItems,
{
pageName = pageName,
item = item,
errorMessage = errorMessage,
}
)
end
-- Item, Slot, numeric columns, and Weight are always present.
local totalColumnCount =
2 +
#NUMERIC_COLUMNS +
1
if showEffectColumn then
totalColumnCount = totalColumnCount + 1
end
if showDroppedByColumn then
totalColumnCount = totalColumnCount + 1
end
--------------------------------------------------------------------
-- Table shell
--------------------------------------------------------------------
local wrapper =
mw.html.create('div')
wrapper
:addClass('eql-gear-set-table-wrap')
:css('max-width', '100%')
:css('overflow-x', 'auto')
:css('overflow-y', 'visible')
:css('-webkit-overflow-scrolling', 'touch')
:css('touch-action', 'pan-x pan-y')
local output =
mw.html.create('table')
output
:addClass('wikitable')
:addClass('sortable')
:addClass('eql-gear-set-table')
:css('text-align', 'center')
:css('touch-action', 'pan-x pan-y')
--------------------------------------------------------------------
-- 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', 'Item')
addHeader(headerRow, 'Slot', 'Equipment slot')
for _, column in ipairs(NUMERIC_COLUMNS) do
addHeader(
headerRow,
column.header,
column.labels[1]
)
end
addHeader(headerRow, 'Wt.', 'Weight')
if showEffectColumn then
addHeader(headerRow, 'Effect', 'Item effects')
end
if showDroppedByColumn then
addHeader(headerRow, 'Dropped By', 'Drop sources')
end
--------------------------------------------------------------------
-- Totals
--------------------------------------------------------------------
local totals = {}
for _, column in ipairs(NUMERIC_COLUMNS) do
totals[column.key] = 0
end
local totalWeight = 0
--------------------------------------------------------------------
-- Item-row renderer
--------------------------------------------------------------------
local function renderItemRow(item)
local row = output:tag('tr')
addCell(
row,
item.hover,
true
)
addCell(
row,
formatSlot(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
if showEffectColumn then
addCell(
row,
item.effect,
true
)
end
if showDroppedByColumn then
addCell(
row,
item.droppedBy,
true
)
end
end
--------------------------------------------------------------------
-- Item rows
--------------------------------------------------------------------
for _, loaded in ipairs(loadedItems) do
local pageName =
loaded.pageName
local item =
loaded.item
local errorMessage =
loaded.errorMessage
if not item then
local errorRow =
output:tag('tr')
errorRow
:tag('td')
:attr(
'colspan',
totalColumnCount
)
:addClass('error')
:css(
'text-align',
'left'
)
:wikitext(
'[[' ..
pageName ..
']]: ' ..
errorMessage
)
else
local isWrist =
item.slot:upper():find(
'WRIST',
1,
true
) ~= nil
local copies = 1
-- If the set supplies exactly one wrist-slot item, render it
-- twice and count its statistics and weight twice. If two or
-- more wrist items were already supplied, leave them alone.
if isWrist
and suppliedWristCount == 1
then
copies = 2
end
for _ = 1, copies do
renderItemRow(item)
end
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)
)
if showEffectColumn then
addCell(totalRow, '', false)
end
if showDroppedByColumn then
addCell(totalRow, '', false)
end
wrapper:node(output)
return tostring(wrapper)
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