Module:MoveTable
Appearance
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