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

Module:ShipCompare: Difference between revisions

Content deleted Content added
Fix: Lua 5.1 does not support alternation (|) in patterns — use separate find() calls for Small/Medium/Large Ships
Revert multi-line parser to line-by-line (the | split broke it); fix size section matching to use separate find() instead of alternation |
Line 153: Line 153:
end
end
else
else
-- Single-pipe format: cells separated by " | " (space-pipe-space).
-- Multi-line format: each | cell on its own line
for each_line in rowText:gmatch('[^\n]+') do
-- This handles multi-line cells where [[File:...]] and remaining
each_line = trim(each_line)
-- pipe-separated values are on a different line from the ship name.
if each_line:find('^|') and not each_line:find('^|%+') and not each_line:find('^|}') then
for part in (rowText .. ' | '):gmatch('(.-) | ') do
local part2 = trim(part)
each_line = each_line:gsub('^|', '')
table.insert(cells, trim(each_line))
part2 = part2:gsub('^|', '')
end
table.insert(cells, trim(part2))
end
end
end
end

Revision as of 17:02, 21 July 2026

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
-- ============================================================

--- 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