Module:ShipCompare
From Vanguard Galaxy Wiki
More actions
Documentation for this module may be created at Module:ShipCompare/doc
-- Module:ShipCompare
-- Side-by-side ship comparison tool for Vanguard Galaxy Wiki
--
-- Reads ship data directly from [[Ship List]] page — no separate data page needed.
-- Ships added to the Ship List page automatically appear here.
--
-- Usage:
-- {{#invoke:ShipCompare|compare|Raptor|Zephyr}}
-- Side-by-side comparison of 2+ ships
--
-- {{#invoke:ShipCompare|class|Cutters}}
-- Table of all ships in the 'Cutters' class
--
-- {{#invoke:ShipCompare|search|Raptor}}
-- Search ships by partial name
--
-- {{#invoke:ShipCompare|stats}}
-- Database statistics
--
-- {{#invoke:ShipCompare|classes}}
-- List all classes with ship counts
local p = {}
-- ============================================================
-- Page reader — fetches and caches the Ship List page
-- ============================================================
local shipListPage = nil
local function getShipListContent()
if shipListPage then
return shipListPage
end
local title = mw.title.new('Ship_List')
if not title then
return nil
end
local content = title:getContent()
shipListPage = content
return content
end
-- ============================================================
-- Wikitable parser — converts Ship List wikitext to Lua data
-- ============================================================
local sizeLabelsDisplay = { 'Small Ships', 'Medium Ships', 'Large Ships' }
local sizeKeys = { 'small', 'medium', 'large' }
local function trim(s)
return mw.text.trim(s or '')
end
--- Parse a wiki table cell value: strip links, HTML, whitespace
local function cleanCell(cell)
cell = trim(cell)
cell = cell:gsub('^|', '')
cell = trim(cell)
-- Extract manufacturer name from [[Link]]
cell = cell:gsub('%[%[([^%[%]|]+)%|?[^%[%]]*%]%]', function(name)
return trim(name)
end)
-- Remove <span> tags but keep their text
cell = cell:gsub('<span[^>]*>', ''):gsub('</span>', '')
-- Remove <br> tags (replace with space)
cell = cell:gsub('<br[^>]*>', ' ')
-- Remove {{beta}} templates
cell = cell:gsub('{{[^}]*}}', '')
cell = cell:gsub('\n+', ' ')
cell = trim(cell)
return cell
end
--- Extract ship name and image filename from the first cell
--- Input: "| Raptor<br>[[File:Raptor.png|frameless|200px]]"
local function parseShipCell(cell)
cell = trim(cell)
cell = cell:gsub('^|', '')
cell = trim(cell)
-- Normalize newlines (from wikitext line breaks) to <br>
cell = cell:gsub('\n+', '<br>')
local name = cell
local image = ''
-- Extract image [[File:...]]
local imgStart, imgEnd = cell:find('%[%[File:')
if imgStart then
local afterFile = cell:sub(imgEnd + 1)
local pipePos = afterFile:find('|')
local closePos = afterFile:find(']]')
if pipePos and (not closePos or pipePos < closePos) then
image = afterFile:sub(1, pipePos - 1)
elseif closePos then
image = afterFile:sub(1, closePos - 1)
end
name = cell:sub(1, imgStart - 1)
end
name = name:gsub('<br[^>]*>', ' ')
name = trim(name)
name = name:gsub('<br[^>]*>$', '')
name = name:gsub('<span[^>]*>', ''):gsub('</span>', '')
name = name:gsub('{{[^}]*}}', '')
name = trim(name)
image = trim(image)
if image ~= '' then
image = image .. '|frameless|200px'
end
return name, image
end
--- Check if a row is a header row (contains ! cells)
local function isHeaderRow(rowText)
for line in rowText:gmatch('[^\n]+') do
line = trim(line)
if line:find('^!') then
return true
end
end
return false
end
--- Parse ship data rows from a sortable wikitable
--- @param tableText string — the content between {| and |} of a sortable table
--- @return table array of ship data records
local function parseShipTable(tableText)
local ships = {}
local rows = mw.text.split(tableText, '|%-')
for ri = 1, #rows do
local rowText = trim(rows[ri])
if rowText == '' then
-- skip
elseif isHeaderRow(rowText) then
-- skip header rows
elseif rowText:find('colspan', 1, true) then
-- skip colspan rows
else
local cells = {}
if rowText:find('||') then
-- Inline format: | cell1 || cell2 || cell3
local parts = mw.text.split(rowText, '||')
for pi = 1, #parts do
local part = trim(parts[pi])
part = part:gsub('^|', '')
table.insert(cells, trim(part))
end
else
-- Multi-line format: each | cell on its own line
for each_line in rowText:gmatch('[^\n]+') do
each_line = trim(each_line)
if each_line:find('^|') and not each_line:find('^|%+') and not each_line:find('^|}') then
each_line = each_line:gsub('^|', '')
table.insert(cells, trim(each_line))
end
end
end
if #cells >= 2 then
local name, image = parseShipCell(cells[1])
if name ~= '' then
local ship = {
name = name,
image = image,
manufacturer = cleanCell(cells[2] or ''),
aux = cleanCell(cells[3] or ''),
hull = cleanCell(cells[4] or ''),
armor = cleanCell(cells[5] or ''),
shield = cleanCell(cells[6] or ''),
cargo = cleanCell(cells[7] or ''),
speed = cleanCell(cells[8] or ''),
accel = cleanCell(cells[9] or ''),
hardpoints = cleanCell(cells[10] or ''),
tonnage = cleanCell(cells[11] or ''),
shipyardRep = cleanCell(cells[12] or ''),
conquest = cleanCell(cells[13] or ''),
playerLevel = cleanCell(cells[14] or ''),
shipyardLevel = cleanCell(cells[15] or ''),
}
table.insert(ships, ship)
end
end
end
end
return ships
end
--- Determine role from class name
local function roleFromClass(className)
if className:find('Cutter') or className:find('Gunship') or className:find('Corvette') or className:find('Frigate') or className:find('Destroyer') or className:find('Battlecruiser') then
return 'Combat'
elseif className:find('Mining') or className:find('Hewers') or className:find('Hewer') or className:find('Dredger') or className:find('Breaker') or className:find('Harvester') or className:find('Barge') then
return 'Mining'
elseif className:find('Salvage') or className:find('Scow') or className:find('Scrapper') or className:find('Wrecker') or className:find('Reclaimer') then
return 'Salvage'
elseif className:find('Courier') or className:find('Ferry') or className:find('Hauler') or className:find('Freighter') or className:find('Carrack') or className:find('Merchantman') then
return 'Cargo'
end
return '—'
end
--- Parse the entire Ship List page into ship data
--- TabberNeue uses |-|ClassName= syntax directly in wikitext (no <tabber> tags)
local function parseAllShips()
local content = getShipListContent()
if not content then
return nil
end
-- Split by size sections
local bySize = {}
local currentSize = nil
for line in content:gmatch('[^\n]+') do
line = trim(line)
local sizeMatch = nil
if line:find("^==%s*Small Ships%s*==$") then sizeMatch = "Small Ships"
elseif line:find("^==%s*Medium Ships%s*==$") then sizeMatch = "Medium Ships"
elseif line:find("^==%s*Large Ships%s*==$") then sizeMatch = "Large Ships"
end
if sizeMatch then
if sizeMatch == 'Small Ships' then currentSize = 'small'
elseif sizeMatch == 'Medium Ships' then currentSize = 'medium'
elseif sizeMatch == 'Large Ships' then currentSize = 'large'
end
elseif currentSize then
if not bySize[currentSize] then
bySize[currentSize] = {}
end
table.insert(bySize[currentSize], line)
end
end
local result = {
ships = {},
byClass = {},
classes = {},
bySize = { small = {}, medium = {}, large = {} },
}
for si = 1, #sizeKeys do
local sizeKey = sizeKeys[si]
if bySize[sizeKey] then
local sectionText = table.concat(bySize[sizeKey], '\n')
-- TabberNeue uses |-|ClassName= for tabs
-- Split by |-| to get individual tabs
-- First part before any |-| is preamble (ignored)
local tabs = mw.text.split(sectionText, '|%-|')
for ti = 1, #tabs do
local tab = trim(tabs[ti])
if tab ~= '' then
-- First line = ClassName= or ClassName="description"
local firstNewline = tab:find('\n')
if firstNewline then
local classNameLine = trim(tab:sub(1, firstNewline - 1))
-- Remove trailing = and any description after it
local eqPos = classNameLine:find('=')
local className = ''
if eqPos then
className = trim(classNameLine:sub(1, eqPos - 1))
else
className = classNameLine
end
if className ~= '' then
-- Find the sortable wikitable
local tableStart = tab:find('%{%| class="wikitable sortable"')
if tableStart then
local tableClose = tab:find('\n%|}', tableStart)
if tableClose then
local tableContent = tab:sub(tableStart, tableClose)
local firstPipe = tableContent:find('\n')
if firstPipe then
tableContent = tableContent:sub(firstPipe + 1)
end
tableContent = tableContent:gsub('\n%|}$', '')
local ships = parseShipTable(tableContent)
local role = roleFromClass(className)
local classInfo = {
name = className,
sizeKey = sizeKey,
sizeLabel = sizeLabelsDisplay[si],
role = role,
count = #ships,
}
table.insert(result.classes, classInfo)
result.byClass[className] = classInfo
if not result.bySize[sizeKey] then
result.bySize[sizeKey] = {}
end
result.bySize[sizeKey][className] = ships
for si2 = 1, #ships do
local ship = ships[si2]
ship.className = className
ship.sizeKey = sizeKey
table.insert(result.ships, ship)
end
end
end
end
end
end
end
end
end
return result
end
-- ============================================================
-- Lookup helpers
-- ============================================================
local function findShip(name, data)
local matches = {}
for i = 1, #data.ships do
local ship = data.ships[i]
if ship.name:lower() == name:lower() then
table.insert(matches, ship)
end
end
return matches
end
local function searchShips(partial, data)
local seen = {}
local results = {}
for i = 1, #data.ships do
local ship = data.ships[i]
if ship.name:lower():find(partial:lower(), 1, true) then
if not seen[ship.name] then
seen[ship.name] = true
local ci = data.byClass[ship.className]
table.insert(results, {
name = ship.name,
manufacturer = ship.manufacturer,
class = ship.className,
size = ci and ci.sizeLabel or '—',
role = ci and ci.role or '—',
})
end
end
end
return results
end
-- ============================================================
-- Output helpers
-- ============================================================
local function escapeName(name)
return name:gsub('|', '|')
end
local function shipHeader(ship)
local image = ''
if ship.image and ship.image ~= '' then
image = '<br>[[File:' .. ship.image .. ']]'
end
return '! style="min-width:180px; text-align:center;" | ' .. escapeName(ship.name) .. image
end
local function formatVal(val)
if val == '' or val == '—' or val == nil then
return '—'
end
return val
end
-- ============================================================
-- Stat row definitions
-- ============================================================
local statRows = {
{ key = 'hull', label = 'Hull HP' },
{ key = 'armor', label = 'Armor HP' },
{ key = 'shield', label = 'Shield HP' },
{ key = 'hardpoints', label = 'Hardpoints' },
{ key = 'cargo', label = 'Cargo (m³)' },
{ key = 'speed', label = 'Warp Speed (ls/s)' },
{ key = 'accel', label = 'Warp Accel (ls/s²)' },
{ key = 'tonnage', label = 'Tonnage' },
{ key = 'aux', label = 'Auxiliary' },
{ key = 'manufacturer', label = 'Manufacturer' },
{ key = 'shipyardRep', label = 'Shipyard Rep' },
{ key = 'conquest', label = 'Conquest Rank' },
{ key = 'playerLevel', label = 'Player Level' },
{ key = 'shipyardLevel', label = 'Shipyard Level' },
}
-- ============================================================
-- Public functions
-- ============================================================
--- TabberNeue class browser: all ships by size and class
--- {{#invoke:ShipCompare|browse}}
function p.browse(frame)
local data = parseAllShips()
if not data then
return '<div class="error">Could not read [[Ship List]] page.</div>'
end
local result = {}
for si = 1, #sizeKeys do
local sizeKey = sizeKeys[si]
table.insert(result, '== ' .. sizeLabelsDisplay[si] .. ' ==')
table.insert(result, '')
table.insert(result, '<tabber>')
if data.bySize[sizeKey] then
for cn, ships in pairs(data.bySize[sizeKey]) do
local ci = data.byClass[cn]
local role = ci and ci.role or '—'
table.insert(result, '|-|' .. cn .. '=')
table.insert(result, '{| class="wikitable sortable" style="width:100%;"')
table.insert(result, '|-')
table.insert(result, '! Ship')
table.insert(result, '! Manufacturer')
table.insert(result, '! Hull')
table.insert(result, '! Armor')
table.insert(result, '! Shield')
table.insert(result, '! Hardpoints')
table.insert(result, '! Cargo')
table.insert(result, '! Speed')
table.insert(result, '! Accel')
table.insert(result, '! Tonnage')
table.insert(result, '! Aux')
table.insert(result, '! Rep')
table.insert(result, '! Level')
for _, ship in ipairs(ships) do
local image = ''
if ship.image and ship.image ~= '' then
image = '<br>[[File:' .. ship.image .. ']]'
end
table.insert(result, '|-')
table.insert(result, '| [[' .. escapeName(ship.name) .. ']]' .. image)
table.insert(result, '| ' .. ship.manufacturer)
table.insert(result, '| ' .. ship.hull)
table.insert(result, '| ' .. ship.armor)
table.insert(result, '| ' .. ship.shield)
table.insert(result, '| ' .. ship.hardpoints)
table.insert(result, '| ' .. ship.cargo)
table.insert(result, '| ' .. ship.speed)
table.insert(result, '| ' .. ship.accel)
table.insert(result, '| ' .. ship.tonnage)
table.insert(result, '| ' .. ship.aux)
table.insert(result, '| ' .. ship.shipyardRep)
table.insert(result, '| ' .. ship.playerLevel)
end
table.insert(result, '|}')
end
end
table.insert(result, '</tabber>')
table.insert(result, '')
end
return table.concat(result, '\n')
end
--- Compare 2+ ships side-by-side
--- {{#invoke:ShipCompare|compare|Ship1|Ship2|Ship3|...}}
function p.compare(frame)
local args = frame.args
local shipNames = {}
for i = 1, 20 do
local arg = args[i]
if arg and trim(arg) ~= '' then
table.insert(shipNames, trim(arg))
end
end
if #shipNames < 2 then
return '<div class="error">ShipCompare.compare needs at least 2 ship names.</div>'
end
local data = parseAllShips()
if not data then
return '<div class="error">Could not read [[Ship List]] page.</div>'
end
local ships = {}
local errors = {}
for i = 1, #shipNames do
local name = shipNames[i]
local matches = findShip(name, data)
if #matches == 0 then
local suggestions = searchShips(name, data)
local msg = "'''" .. escapeName(name) .. "''' not found."
if #suggestions > 0 then
local suggestList = {}
for j = 1, #suggestions do
table.insert(suggestList, "[[" .. suggestions[j].name .. "]]")
end
msg = msg .. " Did you mean: " .. table.concat(suggestList, ", ") .. "?"
end
table.insert(errors, msg)
else
local ship = matches[1]
if #matches > 1 then
ship._dupes = true
ship._dupeCount = #matches
end
table.insert(ships, ship)
end
end
if #errors > 0 then
return '<div class="error">' .. table.concat(errors, '<br>') .. '</div>'
end
local result = {}
table.insert(result, '{| class="wikitable ship-compare" style="margin:0; width:100%;"')
table.insert(result, '|-')
for i = 1, #ships do
table.insert(result, shipHeader(ships[i]))
end
for ri = 1, #statRows do
local row = statRows[ri]
table.insert(result, '|-')
table.insert(result, '! style="text-align:left; min-width:140px;" | ' .. row.label)
for si = 1, #ships do
table.insert(result, '| style="text-align:center;" | ' .. formatVal(ships[si][row.key]))
end
end
table.insert(result, '|-')
table.insert(result, '! style="text-align:left;" | Class')
for i = 1, #ships do
table.insert(result, '| style="text-align:center;" | [[' .. (ships[i].className or '—') .. ']]')
end
table.insert(result, '|}')
for i = 1, #ships do
local ship = ships[i]
if ship._dupes then
table.insert(result, '<div style="font-size:0.85em; color:#666; margin-top:4px;">')
table.insert(result, "Note: '''" .. escapeName(ship.name) .. "''' has " .. ship._dupeCount .. " variants (different manufacturers). Showing the first match.")
table.insert(result, '</div>')
end
end
return table.concat(result, '\n')
end
--- Show all ships in a class
--- {{#invoke:ShipCompare|class|Cutters}}
function p.class(frame)
local args = frame.args
local className = trim(args[1] or '')
if className == '' then
return '<div class="error">ShipCompare.class needs a class name.</div>'
end
local data = parseAllShips()
if not data then
return '<div class="error">Could not read [[Ship List]] page.</div>'
end
local ships = {}
for sizeKey, classes in pairs(data.bySize) do
if classes[className] then
ships = classes[className]
break
end
end
if #ships == 0 then
for sizeKey, classes in pairs(data.bySize) do
for cn, shipList in pairs(classes) do
if cn:lower() == className:lower() then
ships = shipList
className = cn
break
end
end
if #ships > 0 then break end
end
end
if #ships == 0 then
return '<div class="error">Class "' .. escapeName(className) .. '" not found.</div>'
end
local result = {}
table.insert(result, '{| class="wikitable sortable" style="width:100%;"')
table.insert(result, '|-')
table.insert(result, '! Ship')
table.insert(result, '! Manufacturer')
table.insert(result, '! Hull')
table.insert(result, '! Armor')
table.insert(result, '! Shield')
table.insert(result, '! Hardpoints')
table.insert(result, '! Cargo')
table.insert(result, '! Speed')
table.insert(result, '! Accel')
table.insert(result, '! Tonnage')
table.insert(result, '! Aux')
table.insert(result, '! Rep')
table.insert(result, '! Level')
for i = 1, #ships do
local ship = ships[i]
local image = ''
if ship.image and ship.image ~= '' then
image = '<br>[[File:' .. ship.image .. ']]'
end
table.insert(result, '|-')
table.insert(result, '| ' .. escapeName(ship.name) .. image)
table.insert(result, '| ' .. ship.manufacturer)
table.insert(result, '| ' .. ship.hull)
table.insert(result, '| ' .. ship.armor)
table.insert(result, '| ' .. ship.shield)
table.insert(result, '| ' .. ship.hardpoints)
table.insert(result, '| ' .. ship.cargo)
table.insert(result, '| ' .. ship.speed)
table.insert(result, '| ' .. ship.accel)
table.insert(result, '| ' .. ship.tonnage)
table.insert(result, '| ' .. ship.aux)
table.insert(result, '| ' .. ship.shipyardRep)
table.insert(result, '| ' .. ship.playerLevel)
end
table.insert(result, '|}')
return table.concat(result, '\n')
end
--- Search ships by name
--- {{#invoke:ShipCompare|search|Raptor}}
function p.search(frame)
local args = frame.args
local partial = trim(args[1] or '')
if partial == '' then
return '<div class="error">ShipCompare.search needs a search term.</div>'
end
local data = parseAllShips()
if not data then
return '<div class="error">Could not read [[Ship List]] page.</div>'
end
local results = searchShips(partial, data)
if #results == 0 then
return "No ships matching '''" .. escapeName(partial) .. "''' found."
end
local result = {}
table.insert(result, "Ships matching '''" .. escapeName(partial) .. "''':<br>")
table.insert(result, '{| class="wikitable"')
table.insert(result, '|-')
table.insert(result, '! Ship')
table.insert(result, '! Manufacturer')
table.insert(result, '! Class')
table.insert(result, '! Size')
table.insert(result, '! Role')
for i = 1, #results do
local r = results[i]
table.insert(result, '|-')
table.insert(result, '| [[' .. r.name .. ']]')
table.insert(result, '| ' .. r.manufacturer)
table.insert(result, '| [[' .. r.class .. ']]')
table.insert(result, '| ' .. r.size)
table.insert(result, '| ' .. r.role)
end
table.insert(result, '|}')
return table.concat(result, '\n')
end
--- Database statistics
--- {{#invoke:ShipCompare|stats}}
function p.stats(frame)
local data = parseAllShips()
if not data then
return '<div class="error">Could not read [[Ship List]] page.</div>'
end
local result = {}
table.insert(result, '{| class="wikitable" style="margin:0;"')
table.insert(result, '|+ Ship List Statistics')
table.insert(result, '|-')
table.insert(result, '! Size Category')
table.insert(result, '! Classes')
table.insert(result, '! Ships')
local grandTotal = 0
local grandClasses = 0
for si = 1, #sizeKeys do
local sizeKey = sizeKeys[si]
local classCount = 0
local shipCount = 0
if data.bySize[sizeKey] then
for cn, ships in pairs(data.bySize[sizeKey]) do
classCount = classCount + 1
shipCount = shipCount + #ships
end
end
table.insert(result, '|-')
table.insert(result, '| ' .. sizeLabelsDisplay[si])
table.insert(result, '| style="text-align:center;" | ' .. classCount)
table.insert(result, '| style="text-align:center;" | ' .. shipCount)
grandTotal = grandTotal + shipCount
grandClasses = grandClasses + classCount
end
table.insert(result, '|-')
table.insert(result, '! Total')
table.insert(result, '! ' .. grandClasses)
table.insert(result, '! ' .. grandTotal)
table.insert(result, '|}')
return table.concat(result, '\n')
end
--- List all classes
--- {{#invoke:ShipCompare|classes}}
function p.classes(frame)
local data = parseAllShips()
if not data then
return '<div class="error">Could not read [[Ship List]] page.</div>'
end
local result = {}
table.insert(result, '{| class="wikitable"')
table.insert(result, '|-')
table.insert(result, '! Size')
table.insert(result, '! Class')
table.insert(result, '! Role')
table.insert(result, '! Ships')
for si = 1, #sizeKeys do
local sizeKey = sizeKeys[si]
if data.bySize[sizeKey] then
for cn, ships in pairs(data.bySize[sizeKey]) do
local ci = data.byClass[cn]
local role = ci and ci.role or '—'
table.insert(result, '|-')
table.insert(result, '| ' .. sizeLabelsDisplay[si])
table.insert(result, '| [[' .. cn .. ']]')
table.insert(result, '| ' .. role)
table.insert(result, '| style="text-align:center;" | ' .. #ships)
end
end
end
table.insert(result, '|}')
return table.concat(result, '\n')
end
return p