Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

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('|', '&#124;')
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