Jump to content

Module:MoveTable

From Creopedia

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

require( 'strict' )

local TableLua = require( 'Module:TableLua' )
local GameTabs = require( 'Module:GameTabs' )
local Element = require( 'Module:Element' )
local IconLink = require( 'Module:IconLink' )
local Move = require( 'Module:Move' )

local p = {}

--- The games this module can render, and where each one keeps its move data.
--- EvoCreo is a flat list keyed by an UPPER_SNAKE id; EvoCreo 2 carries a
--- display name plus nested lookup objects.
---
--- `costLabel` differs because the games name the stat differently: EvoCreo
--- calls it recharge, EvoCreo 2 calls it energy. Each game has its own tab,
--- so each keeps its own wording.
local GAMES = {
	evocreo = {
		label = 'EvoCreo',
		costLabel = 'Recharge',
		data = 'Module:GameData/EvoCreo/MoveData.json'
	},
	evocreo2 = {
		label = 'EvoCreo 2',
		costLabel = 'Energy',
		data = 'Module:GameData/EvoCreo2/MoveTable.json'
	}
}

--- Values the two games spell differently while meaning the same thing.
--- EvoCreo's HEALING and EvoCreo 2's HEAL are one tier, so the combined table
--- folds them together instead of reporting them as a difference between the
--- games. Genuine reclassifications, such as a move EvoCreo 2 promoted to
--- Super or a heal it moved from Effect to Special, are left alone.
local ENUM_ALIASES = {
	HEALING = 'HEAL'
}

--- Move icons are 36px of pixel art, shown here at half size, which keeps the
--- pixel grid exact and matches the icons the element badges already carry.
local ICON_SIZE = '18px'

--- Moves that are battle mechanics rather than moves a Creo can be taught.
--- No Creo learns them and no tome teaches them; the game plays them on a
--- Creo's behalf, gives them no icon, and leaves the two of them that have no
--- element out of the element listings anyway. They are left out of these
--- tables entirely, and the Moves page describes them in its own section.
local MECHANICS = {
	['Desperate Strike'] = true,
	['Confused Strike'] = true,
	['Rest'] = true
}

--- Normalised move lists, keyed by game id. The combined tables ask for the
--- same list once per element, so the work is done once per page instead.
local moveCache = {}

--- Names of every move EvoCreo 2 carries, for choosing which game's artwork
--- illustrates a move.
local evocreo2Names

--- Turn an UPPER_SNAKE identifier into display text: FIRE_PUNCH -> Fire Punch.
---
--- @param id string
--- @return string
local function toTitle( id )
	local words = {}

	for word in id:gmatch( '[^_]+' ) do
		table.insert( words, word:sub( 1, 1 ):upper() .. word:sub( 2 ):lower() )
	end

	return table.concat( words, ' ' )
end

--- @param value string|nil
--- @return string
local function formatEnum( value )
	if type( value ) ~= 'string' or value == '' or value == 'NONE' then
		return '—'
	end

	return toTitle( ENUM_ALIASES[value:upper()] or value )
end

--- Plain numbers keep the column client-sortable; a dash marks missing data.
--- EvoCreo 2 stores recharge as a string, so values are coerced rather than
--- type-checked.
---
--- @param value number|string|nil
--- @param scale number|nil
--- @return string|number
local function formatNumber( value, scale )
	local number = tonumber( value )

	if number == nil then
		return '—'
	end

	return number * ( scale or 1 )
end

--- Normalise one game's move data into a common shape.
---
--- The two games name these fields differently: EvoCreo's `type` (Physical,
--- Special, Effect) is EvoCreo 2's `moveClass`, and EvoCreo's `skilltype`
--- (Normal, Elite, Heal) is EvoCreo 2's `moveType`. The column names here
--- follow the Creo pages: Category for the first, Type for the second.
---
--- Accuracy is a 0-1 fraction in EvoCreo and already a percentage in
--- EvoCreo 2, so the first game's values are scaled to match.
---
--- EvoCreo also leaves a field out of a move's record when it holds the
--- default, which its data file documents as NORMAL for the skill type. Bite
--- and Scratch are the two moves written that way, and they are Normal moves
--- rather than moves with no tier.
---
--- @param gameId string
--- @return table
local function getMoves( gameId )
	if moveCache[gameId] then
		return moveCache[gameId]
	end

	local moves = mw.loadJsonData( GAMES[gameId].data )
	local list = {}

	for _, move in ipairs( moves ) do
		local record

		if gameId == 'evocreo' then
			record = {
				name = toTitle( move.id ),
				element = move.element,
				category = move.type,
				skillType = move.skilltype or 'NORMAL',
				power = formatNumber( move.basedamage ),
				accuracy = formatNumber( move.accuracy, 100 ),
				cost = formatNumber( move.recharge )
			}
		else
			record = {
				name = move.name,
				element = move.element and move.element.name,
				category = move.moveClass and move.moveClass.name,
				skillType = move.moveType and move.moveType.name,
				power = formatNumber( move.damage ),
				accuracy = formatNumber( move.accuracy ),
				cost = formatNumber( move.recharge )
			}
		end

		if not MECHANICS[record.name] then
			table.insert( list, record )
		end
	end

	moveCache[gameId] = list

	return list
end

--- @param name string
--- @return boolean
local function isInEvoCreo2( name )
	if not evocreo2Names then
		evocreo2Names = {}

		for _, move in ipairs( getMoves( 'evocreo2' ) ) do
			evocreo2Names[move.name] = true
		end
	end

	return evocreo2Names[name] == true
end

--- @param move table
--- @param elementName string|nil
--- @return boolean
local function matchesElement( move, elementName )
	if elementName == nil then
		return true
	end

	return type( move.element ) == 'string'
		and move.element:upper() == elementName:upper()
end

--- Render the element badge, or a dash for a move with no element.
---
--- @param elementName string|nil
--- @return string
local function formatElement( elementName )
	if type( elementName ) ~= 'string' or elementName == '' or elementName == 'NONE' then
		return '—'
	end

	return Element._main( elementName )
end

--- Link a move under its own name, behind its icon, and following the
--- redirect-free article title for the two moves whose name another subject
--- already owns.
---
--- A move is illustrated with its EvoCreo 2 artwork, which is the convention
--- the move infoboxes follow; the older game's icon stands in for the moves
--- that never made it into the sequel.
---
--- @param name string
--- @return string
local function formatLink( name )
	local icon = isInEvoCreo2( name )
		and Move.getThumbnail( name )
		or Move.getThumbnail( name, 'evocreo' )

	return IconLink._main( {
		link = Move.getArticle( name ),
		text = name,
		icon = icon,
		size = ICON_SIZE,
		class = 'cp-image-pixelated'
	} )
end

--- The element column is dropped when the table is already filtered to one
--- element, since every row would repeat the same badge.
---
--- @param showElement boolean
--- @param gameId string
--- @return table
local function getColumns( showElement, gameId )
	local columns = { { label = 'Move' } }

	if showElement then
		table.insert( columns, { label = 'Element' } )
	end

	table.insert( columns, { label = 'Category' } )
	table.insert( columns, { label = 'Type' } )
	table.insert( columns, { label = 'Power', textAlign = 'number' } )
	table.insert( columns, { label = 'Accuracy (%)', textAlign = 'number' } )
	table.insert( columns, { label = GAMES[gameId].costLabel, textAlign = 'number' } )

	return columns
end

--- @param move table
--- @param showElement boolean
--- @return table
local function getRow( move, showElement )
	local row = { formatLink( move.name ) }

	if showElement then
		table.insert( row, formatElement( move.element ) )
	end

	table.insert( row, formatEnum( move.category ) )
	table.insert( row, formatEnum( move.skillType ) )
	table.insert( row, move.power )
	table.insert( row, move.accuracy )
	table.insert( row, move.cost )

	return row
end

--- Render the table for a single game.
---
--- @param gameId string
--- @param elementName string|nil
--- @return string
local function renderTable( gameId, elementName )
	local game = GAMES[gameId]

	if not game then
		error( 'Unknown game: ' .. tostring( gameId ) )
	end

	local showElement = elementName == nil
	local rows = {}

	for _, move in ipairs( getMoves( gameId ) ) do
		if matchesElement( move, elementName ) then
			table.insert( rows, getRow( move, showElement ) )
		end
	end

	local caption

	if elementName then
		caption = string.format( '%s moves in %s', toTitle( elementName ), game.label )
	else
		caption = string.format( 'Moves in %s', game.label )
	end

	return TableLua.render( {
		caption = caption,
		hideCaption = true,
		columns = getColumns( showElement, gameId ),
		data = rows,
		class = 'sortable'
	} )
end

--- Wrap a table so the tabbed card styles can stretch it to the panel edges.
---
--- @param wikitext string
--- @return string
local function wrapForCard( wikitext )
	return tostring( mw.html.create( 'div' )
		:addClass( 't-tabbedCard-table-container' )
		:wikitext( wikitext ) )
end

--- Render one tab per game inside a tabbed card, the same presentation the
--- Creo pages use for their move tables.
---
--- @param elementName string|nil
--- @return string
local function renderTabs( elementName )
	local args = { noPadding = true }

	for _, game in ipairs( GameTabs.getGames() ) do
		if GAMES[game.id] then
			args[game.id] = wrapForCard( renderTable( game.id, elementName ) )
		end
	end

	return GameTabs._main( args )
end

--------------------------------------------------------------------------
--- Combined table
---
--- One row per move across both games, listing which games it appears in.
--- Power, accuracy and the energy cost are left out: they were rebalanced
--- between the games and a single column could not honestly hold both.
--------------------------------------------------------------------------

--- Join the games' lists into one record per move.
---
--- Moves are matched on name, which works because EvoCreo's UPPER_SNAKE ids
--- title-case to exactly the names EvoCreo 2 stores: FIRE_PUNCH and
--- Fire Punch are the same move. Each record keeps every game's own category
--- and type rather than a merged value, because the games disagree often
--- enough to matter. EvoCreo has no Super tier at all.
---
--- @param elementName string|nil
--- @return table[]
local function getCombinedMoves( elementName )
	local byName = {}
	local combined = {}

	for _, game in ipairs( GameTabs.getGames() ) do
		if GAMES[game.id] then
			for _, move in ipairs( getMoves( game.id ) ) do
				if matchesElement( move, elementName ) then
					local record = byName[move.name]

					if not record then
						record = {
							name = move.name,
							element = move.element,
							games = {}
						}

						byName[move.name] = record
						table.insert( combined, record )
					end

					table.insert( record.games, {
						label = game.name,
						category = move.category,
						skillType = move.skillType
					} )
				end
			end
		end
	end

	table.sort( combined, function ( a, b )
		return a.name < b.name
	end )

	return combined
end

--- Render a field each game answers for itself: one value where the games
--- agree, or where only one of them has the move, and one labelled line per
--- game where they do not.
---
--- @param record table
--- @param key string
--- @return string
local function formatAcrossGames( record, key )
	local first = formatEnum( record.games[1][key] )
	local lines = {}
	local differs = false

	for _, game in ipairs( record.games ) do
		local value = formatEnum( game[key] )

		if value ~= first then
			differs = true
		end

		table.insert( lines, string.format(
			'%s <small>(%s)</small>', value, game.label
		) )
	end

	if not differs then
		return first
	end

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

--- @param record table
--- @return string
local function formatGames( record )
	local links = {}

	for _, game in ipairs( record.games ) do
		table.insert( links, string.format( '[[%s]]', game.label ) )
	end

	return table.concat( links, ', ' )
end

--- @param showElement boolean
--- @return table
local function getCombinedColumns( showElement )
	local columns = { { label = 'Move' } }

	if showElement then
		table.insert( columns, { label = 'Element' } )
	end

	table.insert( columns, { label = 'Category' } )
	table.insert( columns, { label = 'Type' } )
	table.insert( columns, { label = 'Games' } )

	return columns
end

--- The caption is written for screen readers but not shown, matching the
--- per-game tables. A page stacking one table per element labels them with
--- its own section headings, so a visible caption would only repeat those.
---
--- @param elementName string|nil
--- @return string
local function getCombinedCaption( elementName )
	if elementName == nil then
		return 'All moves'
	end

	return string.format( '%s moves', toTitle( elementName ) )
end

--- @param elementName string|nil
--- @return string
local function renderCombinedTable( elementName )
	local showElement = elementName == nil
	local rows = {}

	for _, record in ipairs( getCombinedMoves( elementName ) ) do
		local row = { formatLink( record.name ) }

		if showElement then
			table.insert( row, formatElement( record.element ) )
		end

		table.insert( row, formatAcrossGames( record, 'category' ) )
		table.insert( row, formatAcrossGames( record, 'skillType' ) )
		table.insert( row, formatGames( record ) )

		table.insert( rows, row )
	end

	return TableLua.render( {
		caption = getCombinedCaption( elementName ),
		hideCaption = true,
		columns = getCombinedColumns( showElement ),
		data = rows,
		class = 'sortable'
	} )
end

--- Wikitext entry point for the module
---
--- @param frame mw.frame
--- @return string
function p.main( frame )
	local getArgs = require( 'Module:Arguments' ).getArgs
	return p._main( getArgs( frame ) )
end

--- Lua entry point for the module
---
--- With no `game` argument every game gets its own tab; passing `game`
--- renders that one game's table on its own, and `game=all` renders a single
--- table spanning the games with a Games column in place of the stats.
---
--- @param args table
--- @return string
function p._main( args )
	args = args or {}

	local elementName = args.element

	if elementName == '' then
		elementName = nil
	end

	local gameId = args.game or args[1]

	if gameId == 'all' then
		return renderCombinedTable( elementName )
	end

	if gameId then
		return renderTable( gameId, elementName )
	end

	return renderTabs( elementName )
end

return p