Module:Learnsets: Difference between revisions

From Pokemon Revolution Online Wiki
Jump to navigation Jump to search
Cae (talk | contribs)
No edit summary
Cae (talk | contribs)
No edit summary
 
(34 intermediate revisions by the same user not shown)
Line 23: Line 23:


local moveDataCache = nil
local moveDataCache = nil
local function blankToNil(v)
    if v == nil or v == '' then
        return nil
    end
    return v
end


local function loadMoveData()
local function loadMoveData()
Line 49: Line 56:


     moveDataCache = {}
     moveDataCache = {}
     if csvData and csvData.Name then
     if csvData then
         for i, name in ipairs(csvData.Name) do
         for _, row in ipairs(csvData) do
             moveDataCache[name] = {
             moveDataCache[row.Name] = {
                 type = csvData.Type[i] or 'Normal',
                 type = blankToNil(row.Type) or 'Normal',
                 category = csvData.Category[i] or 'Status',
                 category = blankToNil(row.Category) or 'Status',
                 power = csvData.Power[i],
                 power = blankToNil(row.Power),
                 accuracy = csvData.Accuracy[i] or '—',
                 accuracy = blankToNil(row.Accuracy),
                 pp = csvData.PP[i],
                 pp = blankToNil(row.PP),
                 broken = csvData.Broken[i] or 'N',
                 broken = row.Broken or 'N',
                 typeless = csvData.Typeless[i] or 'N',
                 typeless = row.Typeless or 'N',
                 priority = csvData.Priority[i] or '0',
                 priority = blankToNil(row.Priority) or '0',
                 tm = csvData.TM[i],
                 tm = blankToNil(row.TM),
                 move_tutor = csvData['Move Tutor'][i],
                 move_tutor = blankToNil(row['Move Tutor']),
                 egg = csvData.Egg[i]
                 egg = blankToNil(row.Egg)
             }
             }
         end
         end
     end
     end
   
 
     return moveDataCache
     return moveDataCache
end
end
Line 92: Line 99:
     end
     end
     return raw, nil
     return raw, nil
end
local function sentenceCase(s)
    local first = mw.ustring.sub(s, 1, 1)
    local rest = mw.ustring.sub(s, 2)
    return mw.ustring.upper(first) .. mw.ustring.lower(rest)
end
local FORM_SUFFIXES = {
    { suffix = 'Alolan', label = 'Alolan', rawList = 'prefix' },
    { suffix = 'Galar', label = 'Galarian', rawList = 'prefix' },
    { suffix = 'Hisui', label = 'Hisuian', rawList = 'prefix' },
    { suffix = 'Paldea', label = 'Paldean', rawList = 'prefix' },
    { suffix = 'Sandy', label = 'Sandy', rawList = 'suffix' },
    { suffix = 'Trash', label = 'Trash', rawList = 'suffix' },
    { suffix = 'Sky', label = 'Sky', rawList = 'suffix' },
    { suffix = 'Fan', label = 'Fan', rawList = 'suffix' },
    { suffix = 'Frost', label = 'Frost', rawList = 'suffix' },
    { suffix = 'Heat', label = 'Heat', rawList = 'suffix' },
    { suffix = 'Mow', label = 'Mow', rawList = 'suffix' },
    { suffix = 'Wash', label = 'Wash', rawList = 'suffix' },
    { suffix = 'Ash', label = 'Ash', learnsetStyle = 'prefix-space', rawList = 'prefix-hyphen' },
    { suffix = 'Bloodmoon', label = 'Bloodmoon', rawList = 'suffix' },
    { suffix = 'Low-Key', label = 'Low Key', rawList = 'suffix-label' },
    { suffix = 'F', label = 'Female', rawList = 'suffix-space', baseLabel = 'Male' },
    { suffix = 'Blue-Striped', label = 'Blue-Striped', rawList = 'suffix-label' },
    { suffix = 'White-Striped', label = 'White-Striped', rawList = 'suffix-label' },
    { suffix = 'Rainy', label = 'Rainy', rawList = 'suffix-label' },
    { suffix = 'Snowy', label = 'Snowy', rawList = 'suffix-label' },
    { suffix = 'Sunny', label = 'Sunny', rawList = 'suffix-label' },
    { suffix = 'Sunshine', label = 'Sunshine', rawList = 'suffix-label' },
    { suffix = 'Attack', label = 'Attack', rawList = 'suffix-label' },
    { suffix = 'Defense', label = 'Defense', rawList = 'suffix-label' },
    { suffix = 'Speed', label = 'Speed', rawList = 'suffix-label' },
    { suffix = 'Unbound', label = 'Unbound', rawList = 'suffix-label' },
    { suffix = 'Black', label = 'Black', rawList = 'suffix-label' },
    { suffix = 'White', label = 'White', rawList = 'suffix-label' },
    { suffix = 'Dusk', label = 'Dusk', rawList = 'suffix-label' },
    { suffix = 'Midnight', label = 'Midnight', rawList = 'suffix-label' },
    { suffix = 'Pirouette', label = 'Pirouette', rawList = 'suffix-label' },
    { suffix = 'Yellow', label = 'Yellow', rawList = 'suffix-label' },
    { suffix = 'School', label = 'School', rawList = 'suffix-label' },
    { suffix = 'Crowned', label = 'Crowned', rawList = 'suffix-label' },
}
local STRIP_SUFFIXES = {
    'Alolan', 'Galar', 'Hisui', 'Paldea', 'Sandy', 'Trash', 'Plant', 'Amped', 'Male',
    'Ice-Face', 'Midday', 'Solo',
}
local function stripKnownFormSuffix(name)
    for _, suffix in ipairs(STRIP_SUFFIXES) do
        local tail = '-' .. suffix
        if mw.ustring.sub(name, -mw.ustring.len(tail)) == tail then
            return mw.ustring.sub(name, 1, -mw.ustring.len(tail) - 1)
        end
    end
    return nil
end
end


local learnsetCache = {}
local learnsetCache = {}
local learnsetErrorCache = {}
local learnsetErrorCache = {}
local function primeLearnsetCache(names)
    local uniqueNames, seen = {}, {}
    for _, name in ipairs(names) do
        if not seen[name] and learnsetCache[name] == nil and learnsetErrorCache[name] == nil then
            seen[name] = true
            table.insert(uniqueNames, name)
        end
    end
    if #uniqueNames == 0 then
        return
    end
    local dataSpec = {}
    local keyToName = {}
    for i, name in ipairs(uniqueNames) do
        local key = 'k' .. i
        dataSpec[key] = '$[' .. mw.text.jsonEncode(name) .. ']'
        keyToName[key] = name
    end
    local ok, result, errors = pcall(function()
        return mw.ext.externalData.getFileData({
            source = 'data',
            ['file name'] = 'Learnsets.json',
            format = 'JSON',
            ['use jsonpath'] = true,
            data = dataSpec,
        })
    end)
    if not ok then
        local msg = 'Lua error: ' .. tostring(result)
        for _, name in ipairs(uniqueNames) do
            learnsetErrorCache[name] = msg
        end
        return
    end
    if errors and #errors > 0 then
        local msg = 'ExternalData error: ' .. table.concat(errors, ' | ')
        for _, name in ipairs(uniqueNames) do
            learnsetErrorCache[name] = msg
        end
        return
    end
    for key, name in pairs(keyToName) do
        local val = result and result[key]
        if type(val) == 'table' then
            learnsetCache[name] = val
        else
            learnsetErrorCache[name] = 'No learnset data found for ' .. name
        end
    end
end


local function getCachedLearnset(pokemonName)
local function getCachedLearnset(pokemonName)
     if learnsetCache[pokemonName] == nil and learnsetErrorCache[pokemonName] == nil then
     if learnsetCache[pokemonName] == nil and learnsetErrorCache[pokemonName] == nil then
         learnsetCache[pokemonName], learnsetErrorCache[pokemonName] = loadLearnset(pokemonName)
        local poke, err = loadLearnset(pokemonName)
 
        if not poke then
            local altName = sentenceCase(pokemonName)
            if altName ~= pokemonName then
                local altPoke = loadLearnset(altName)
                if altPoke then
                    poke, err = altPoke, nil
                end
            end
        end
 
         learnsetCache[pokemonName], learnsetErrorCache[pokemonName] = poke, err
     end
     end
     return learnsetCache[pokemonName], learnsetErrorCache[pokemonName]
     return learnsetCache[pokemonName], learnsetErrorCache[pokemonName]
end
end


-- Loads Type1/Type2 for every Pokemon from the PokemonRawList CSV.
local function resolvePagePokemon(pokemonName)
    local poke, err = getCachedLearnset(pokemonName)
    if poke then
        return poke, nil, pokemonName
    end
 
    local baseName = stripKnownFormSuffix(pokemonName)
    if baseName then
        local basePoke, baseErr = getCachedLearnset(baseName)
        if basePoke then
            return basePoke, nil, baseName
        end
    end
 
    return nil, err, pokemonName
end
 
local pokemonTypeCache = nil
local pokemonTypeCache = nil


Line 121: Line 268:


     pokemonTypeCache = {}
     pokemonTypeCache = {}
     if csvData and csvData.Name then
     if csvData then
         for i, name in ipairs(csvData.Name) do
         for _, row in ipairs(csvData) do
             pokemonTypeCache[name] = {
             pokemonTypeCache[row.Name] = {
                 type1 = csvData.Type1[i] or '',
                 type1 = row.Type1 or '',
                 type2 = csvData.Type2[i] or '',
                 type2 = row.Type2 or '',
             }
             }
         end
         end
Line 132: Line 279:
end
end


local typeColors = {
local typeColorCache = {}
    Normal = { bg = "A8A878", border = "6D6D4E" },
local function typeColor(frame, pokeType)
    Fire = { bg = "F08030", border = "9C531F" },
     pokeType = (pokeType and pokeType ~= '') and pokeType or 'Normal'
     Water = { bg = "6890F0", border = "445E9C" },
     if not typeColorCache[pokeType] then
     Electric = { bg = "F8D030", border = "A1871F" },
        typeColorCache[pokeType] = {
    Grass = { bg = "78C850", border = "4E8234" },
            light = frame:expandTemplate{ title = pokeType .. ' color light' },
    Ice = { bg = "98D8D8", border = "638D8D" },
            base = frame:expandTemplate{ title = pokeType .. ' color' },
    Fighting = { bg = "C03028", border = "7D1F1A" },
            dark = frame:expandTemplate{ title = pokeType .. ' color dark' },
    Poison = { bg = "A040A0", border = "682A68" },
        }
    Ground = { bg = "E0C068", border = "927D44" },
     end
     Flying = { bg = "A890F0", border = "6D5E9C" },
     return typeColorCache[pokeType]
     Psychic = { bg = "F85888", border = "A13959" },
end
    Bug = { bg = "A8B820", border = "6D7815" },
    Rock = { bg = "B8A038", border = "786824" },
    Ghost = { bg = "705898", border = "493963" },
    Dragon = { bg = "7038F8", border = "4924A1" },
    Dark = { bg = "705848", border = "49392F" },
    Steel = { bg = "B8B8D0", border = "787887" },
    Fairy = { bg = "EE99AC", border = "9B6470" },
    Shadow = { bg = "5A5A5A", border = "2D2D2D" }
}


local categoryColors = {
local categoryColorCache = {}
     Physical = { bg = "C92112", border = "7D1F1A" },
local function categoryColor(frame, category)
     Special = { bg = "4F5870", border = "353B51" },
     category = (category and category ~= '') and category or 'Status'
    Status = { bg = "8C888C", border = "5A5A5A" }
     if not categoryColorCache[category] then
}
        categoryColorCache[category] = {
            base = frame:expandTemplate{ title = category .. ' color' },
            dark = frame:expandTemplate{ title = category .. ' color dark' },
        }
    end
    return categoryColorCache[category]
end


local function isSTAB(moveType, primaryType, secondaryType)
local function isSTAB(moveType, primaryType, secondaryType)
Line 164: Line 308:
end
end


local function getBorderColor(primaryType)
local function formatItemNumber(label, num)
     return typeColors[primaryType] and typeColors[primaryType].border or "000000"
    num = tonumber(num)
    if not num then
        return '—'
    end
     return label .. string.format('%02d', num)
end
end


Line 182: Line 330:


     local moves = pokemon.level_up or {}
     local moves = pokemon.level_up or {}
      
     local colors = typeColor(frame, primaryType)
 
     local html = mw.html.create('div')
     local html = mw.html.create('div')
        :css('width', '77%')
        :css('margin', 'auto')
     local table = html:tag('table')
     local table = html:tag('table')
         :css('width', '77%')
        :addClass('sortable')
         :css('width', '100%')
         :css('text-align', 'center')
         :css('text-align', 'center')
         :css('margin', 'auto')
         :css('border-radius', '15px 15px 0 0')
         :css('border-radius', '15px')
         :css('border', '3px solid #' .. colors.dark)
         :css('border', '3px solid #' .. getBorderColor(primaryType))
         :css('border-bottom', 'none')
         :css('background-color', '#F5F5F5')
         :css('background-color', '#' .. colors.base)
         :css('padding', '7px')
         :css('padding', '7px')
   
 
         local header = table:tag('tr')
         local header = table:tag('tr')
         :css('background-color', '#E0E0E0')
         :css('background-color', '#' .. colors.light)
     header:tag('th'):wikitext('Level'):css('width', '14%'):css('border-radius', '15px 1px 1px 1px')
        :css('color', '#' .. colors.dark)
     header:tag('th'):wikitext('Move'):css('width', '17%')
     header:tag('th'):wikitext('Level'):css('width', '10%'):css('border-radius', '15px 1px 1px 1px')
     header:tag('th'):wikitext('Type'):css('width', '22%')
     header:tag('th'):wikitext('Move'):css('width', '15%')
     header:tag('th'):wikitext('Category'):css('width', '21%')
     header:tag('th'):wikitext('Type'):css('width', '17%')
     header:tag('th'):wikitext('Base Power'):css('width', '26%')
     header:tag('th'):wikitext('Category'):css('width', '16%')
     header:tag('th'):wikitext('Accuracy'):css('width', '13%')
     header:tag('th'):wikitext('Base Power'):css('width', '17%')
     header:tag('th'):wikitext('PP'):css('width', '5%'):css('border-radius', '1px 15px 1px 1px')
     header:tag('th'):wikitext('Accuracy'):css('width', '15%')
   
     header:tag('th'):wikitext('PP'):css('width', '10%'):css('border-radius', '1px 15px 1px 1px')
 
         for _, move in ipairs(moves) do
         for _, move in ipairs(moves) do
         local moveName = move.move
         local moveName = move.move
         local level = move.level
         local level = move.level
         local moveInfo = moveData[moveName] or {}
         local moveInfo = moveData[moveName] or {}
       
 
         local row = table:tag('tr'):css('background-color', '#FFFFFF')
         local row = table:tag('tr'):css('background-color', '#FFFFFF')
         row:tag('td'):wikitext(tostring(level))
         row:tag('td'):wikitext(tostring(level))
       
 
         local moveLink = '[[' .. moveName .. ']]'
         local moveLink = '[[' .. moveName .. ']]'
         if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
         if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
Line 219: Line 372:
         end
         end
         row:tag('td'):wikitext(moveLink)
         row:tag('td'):wikitext(moveLink)
       
 
         local typeColor = typeColors[moveInfo.type] or typeColors.Normal
         local moveTypeColors = typeColor(frame, moveInfo.type)
         row:tag('td')
         row:tag('td')
             :css('background-color', '#' .. typeColor.bg)
             :css('background-color', '#' .. moveTypeColors.base)
             :css('border', '2px solid #' .. typeColor.border)
             :css('border', '2px solid #' .. moveTypeColors.dark)
             :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')
             :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')
       
 
         local catColor = categoryColors[moveInfo.category] or categoryColors.Status
         local moveCatColors = categoryColor(frame, moveInfo.category)
         row:tag('td')
         row:tag('td')
             :css('background-color', '#' .. catColor.bg)
             :css('background-color', '#' .. moveCatColors.base)
             :css('border', '2px solid #' .. catColor.border)
             :css('border', '2px solid #' .. moveCatColors.dark)
             :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')
             :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')
       
 
         row:tag('td'):wikitext(moveInfo.power or '—')
         row:tag('td'):wikitext(moveInfo.power or '—')
         row:tag('td'):wikitext(moveInfo.accuracy or '—')
         row:tag('td'):wikitext(moveInfo.accuracy or '—')
         row:tag('td'):wikitext(moveInfo.pp or '—')
         row:tag('td'):wikitext(moveInfo.pp or '—')
     end
     end
   
 
     table:tag('tr'):tag('td'):attr('colspan', '7')
     html:tag('div')
         :css('text-align', 'left')
         :css('text-align', 'left')
         :css('background-color', '#E0E0E0')
         :css('background-color', '#' .. colors.light)
         :css('padding', '10px')
         :css('padding', '10px')
         :css('border-radius', '1px 1px 25px 25px')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-top', 'none')
         :css('border-radius', '0 0 15px 15px')
         :wikitext([=[
         :wikitext([=[
* '''Emboldened''' moves denote this Pokémon avails from a STAB bonus for it.
* '''Emboldened''' moves denote this Pokémon avails from a STAB bonus for it.
Line 264: Line 419:
     local tms = pokemon.tms or {}
     local tms = pokemon.tms or {}
     local hms = pokemon.hms or {}
     local hms = pokemon.hms or {}
      
     local colors = typeColor(frame, primaryType)
 
     local html = mw.html.create('div')
     local html = mw.html.create('div')
        :css('width', '77%')
        :css('margin', 'auto')
     local table = html:tag('table')
     local table = html:tag('table')
         :css('width', '77%')
        :addClass('sortable')
         :css('width', '100%')
         :css('text-align', 'center')
         :css('text-align', 'center')
         :css('margin', 'auto')
         :css('border-radius', '25px 25px 0 0')
         :css('border-radius', '25px')
         :css('border', '3px solid #' .. colors.dark)
         :css('border', '3px solid #' .. getBorderColor(primaryType))
         :css('border-bottom', 'none')
         :css('background-color', '#F5F5F5')
         :css('background-color', '#' .. colors.base)
         :css('padding', '7px')
         :css('padding', '7px')
   
 
     local header = table:tag('tr')
     local header = table:tag('tr')
         :css('background-color', '#E0E0E0')
         :css('background-color', '#' .. colors.light)
     header:tag('th'):attr('colspan', '2'):wikitext('TM'):css('width', '11%'):css('border-top-left-radius', '25px')
        :css('color', '#' .. colors.dark)
     header:tag('th'):wikitext('Move'):css('width', '18%')
     header:tag('th'):attr('colspan', '2'):wikitext('TM'):css('width', '10%'):css('border-top-left-radius', '25px')
     header:tag('th'):wikitext('Move'):css('width', '15%')
     header:tag('th'):wikitext('Type'):css('width', '17%')
     header:tag('th'):wikitext('Type'):css('width', '17%')
     header:tag('th'):wikitext('Category'):css('width', '19%')
     header:tag('th'):wikitext('Category'):css('width', '16%')
     header:tag('th'):wikitext('Base Power'):css('width', '26%')
     header:tag('th'):wikitext('Base Power'):css('width', '17%')
     header:tag('th'):wikitext('Accuracy'):css('width', '20%')
     header:tag('th'):wikitext('Accuracy'):css('width', '15%')
     header:tag('th'):wikitext('PP'):css('width', '7%'):css('border-top-right-radius', '25px')
     header:tag('th'):wikitext('PP'):css('width', '10%'):css('border-top-right-radius', '25px')
   
 
     for _, moveName in ipairs(tms) do
     for _, moveName in ipairs(tms) do
         local moveInfo = moveData[moveName] or {}
         local moveInfo = moveData[moveName] or {}
         local row = table:tag('tr'):css('background-color', '#FFFFFF')
         local row = table:tag('tr'):css('background-color', '#FFFFFF')
       
 
        local tmNum = moveInfo.tm or ''
         row:tag('td'):attr('colspan', '2'):wikitext(formatItemNumber('TM', moveInfo.tm))
         row:tag('td'):attr('colspan', '2'):wikitext(tmNum ~= '' and tmNum or 'TM')
 
       
         local moveLink = '[[' .. moveName .. ']]'
         local moveLink = '[[' .. moveName .. ']]'
         if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
         if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
Line 300: Line 459:
         end
         end
         row:tag('td'):wikitext(moveLink)
         row:tag('td'):wikitext(moveLink)
       
 
         local typeColor = typeColors[moveInfo.type] or typeColors.Normal
         local moveTypeColors = typeColor(frame, moveInfo.type)
         row:tag('td')
         row:tag('td')
             :css('background-color', '#' .. typeColor.bg)
             :css('background-color', '#' .. moveTypeColors.base)
             :css('border', '2px solid #' .. typeColor.border)
             :css('border', '2px solid #' .. moveTypeColors.dark)
             :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')
             :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')
       
 
         local catColor = categoryColors[moveInfo.category] or categoryColors.Status
         local moveCatColors = categoryColor(frame, moveInfo.category)
         row:tag('td')
         row:tag('td')
             :css('background-color', '#' .. catColor.bg)
             :css('background-color', '#' .. moveCatColors.base)
             :css('border', '2px solid #' .. catColor.border)
             :css('border', '2px solid #' .. moveCatColors.dark)
             :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')
             :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')
       
 
         row:tag('td'):wikitext(moveInfo.power or '—')
         row:tag('td'):wikitext(moveInfo.power or '—')
         row:tag('td'):wikitext(moveInfo.accuracy or '—')
         row:tag('td'):wikitext(moveInfo.accuracy or '—')
         row:tag('td'):wikitext(moveInfo.pp or '—')
         row:tag('td'):wikitext(moveInfo.pp or '—')
     end
     end
   
 
     for _, moveName in ipairs(hms) do
     for _, moveName in ipairs(hms) do
         local moveInfo = moveData[moveName] or {}
         local moveInfo = moveData[moveName] or {}
         local row = table:tag('tr'):css('background-color', '#FFFFCC')
         local row = table:tag('tr'):css('background-color', '#FFFFCC')
       
 
         row:tag('td'):attr('colspan', '2'):wikitext('HM'):css('font-weight', 'bold')
         row:tag('td'):attr('colspan', '2'):wikitext(formatItemNumber('HM', moveInfo.tm)):css('font-weight', 'bold')
       
 
         local moveLink = '[[' .. moveName .. ']]'
         local moveLink = '[[' .. moveName .. ']]'
         if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
         if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
Line 332: Line 491:
         end
         end
         row:tag('td'):wikitext(moveLink)
         row:tag('td'):wikitext(moveLink)
       
 
         local typeColor = typeColors[moveInfo.type] or typeColors.Normal
         local moveTypeColors = typeColor(frame, moveInfo.type)
         row:tag('td')
         row:tag('td')
             :css('background-color', '#' .. typeColor.bg)
             :css('background-color', '#' .. moveTypeColors.base)
             :css('border', '2px solid #' .. typeColor.border)
             :css('border', '2px solid #' .. moveTypeColors.dark)
             :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')
             :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')
       
 
         local catColor = categoryColors[moveInfo.category] or categoryColors.Status
         local moveCatColors = categoryColor(frame, moveInfo.category)
         row:tag('td')
         row:tag('td')
             :css('background-color', '#' .. catColor.bg)
             :css('background-color', '#' .. moveCatColors.base)
             :css('border', '2px solid #' .. catColor.border)
             :css('border', '2px solid #' .. moveCatColors.dark)
             :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')
             :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')
       
 
         row:tag('td'):wikitext(moveInfo.power or '—')
         row:tag('td'):wikitext(moveInfo.power or '—')
         row:tag('td'):wikitext(moveInfo.accuracy or '—')
         row:tag('td'):wikitext(moveInfo.accuracy or '—')
         row:tag('td'):wikitext(moveInfo.pp or '—')
         row:tag('td'):wikitext(moveInfo.pp or '—')
     end
     end
   
 
     table:tag('tr'):tag('td'):attr('colspan', '8')
     html:tag('div')
         :css('text-align', 'left')
         :css('text-align', 'left')
         :css('background-color', '#E0E0E0')
         :css('background-color', '#' .. colors.light)
         :css('padding', '10px')
         :css('padding', '10px')
         :css('border-radius', '1px 1px 25px 25px')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-top', 'none')
         :css('border-radius', '0 0 25px 25px')
         :wikitext([=[
         :wikitext([=[
* '''Emboldened''' moves denote that this Pokémon avails from a STAB bonus for it.
* '''Emboldened''' moves denote that this Pokémon avails from a STAB bonus for it.
Line 381: Line 542:
     end
     end
      
      
    local colors = typeColor(frame, primaryType)
     local html = mw.html.create('div')
     local html = mw.html.create('div')
        :css('width', '77%')
        :css('margin', 'auto')
     local table = html:tag('table')
     local table = html:tag('table')
         :css('width', '77%')
        :addClass('sortable')
         :css('width', '100%')
         :css('text-align', 'center')
         :css('text-align', 'center')
         :css('margin', 'auto')
         :css('border-radius', '25px 25px 0 0')
         :css('border-radius', '25px')
         :css('border', '3px solid #' .. colors.dark)
         :css('border', '3px solid #' .. getBorderColor(primaryType))
         :css('border-bottom', 'none')
         :css('background-color', '#F5F5F5')
         :css('background-color', '#' .. colors.base)
         :css('padding', '7px')
         :css('padding', '7px')
   
 
     local header = table:tag('tr'):css('background-color', '#E0E0E0')
     local header = table:tag('tr')
     header:tag('th'):wikitext('Move'):css('border-top-left-radius', '25px')
        :css('background-color', '#' .. colors.light)
     header:tag('th'):wikitext('Type')
        :css('color', '#' .. colors.dark)
     header:tag('th'):wikitext('Category')
     header:tag('th'):wikitext('Move'):css('width', '20%'):css('border-top-left-radius', '25px')
     header:tag('th'):wikitext('Base Power')
     header:tag('th'):wikitext('Type'):css('width', '17%')
     header:tag('th'):wikitext('Accuracy')
     header:tag('th'):wikitext('Category'):css('width', '16%')
     header:tag('th'):wikitext('PP'):css('border-top-right-radius', '25px')
     header:tag('th'):wikitext('Base Power'):css('width', '17%')
   
     header:tag('th'):wikitext('Accuracy'):css('width', '15%')
     header:tag('th'):wikitext('PP'):css('width', '15%'):css('border-top-right-radius', '25px')
 
     for _, moveName in ipairs(moves) do
     for _, moveName in ipairs(moves) do
         local moveInfo = moveData[moveName] or {}
         local moveInfo = moveData[moveName] or {}
         local row = table:tag('tr'):css('background-color', '#FFFFFF')
         local row = table:tag('tr'):css('background-color', '#FFFFFF')
       
 
         local moveLink = '[[' .. moveName .. ']]'
         local moveLink = '[[' .. moveName .. ']]'
         if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
         if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
Line 411: Line 579:
         end
         end
         row:tag('td'):wikitext(moveLink)
         row:tag('td'):wikitext(moveLink)
       
 
         local typeColor = typeColors[moveInfo.type] or typeColors.Normal
         local moveTypeColors = typeColor(frame, moveInfo.type)
         row:tag('td')
         row:tag('td')
             :css('background-color', '#' .. typeColor.bg)
             :css('background-color', '#' .. moveTypeColors.base)
             :css('border', '2px solid #' .. typeColor.border)
             :css('border', '2px solid #' .. moveTypeColors.dark)
             :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')
             :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')
       
 
         local catColor = categoryColors[moveInfo.category] or categoryColors.Status
         local moveCatColors = categoryColor(frame, moveInfo.category)
         row:tag('td')
         row:tag('td')
             :css('background-color', '#' .. catColor.bg)
             :css('background-color', '#' .. moveCatColors.base)
             :css('border', '2px solid #' .. catColor.border)
             :css('border', '2px solid #' .. moveCatColors.dark)
             :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')
             :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')
       
 
         row:tag('td'):wikitext(moveInfo.power or '—')
         row:tag('td'):wikitext(moveInfo.power or '—')
         row:tag('td'):wikitext(moveInfo.accuracy or '—')
         row:tag('td'):wikitext(moveInfo.accuracy or '—')
         row:tag('td'):wikitext(moveInfo.pp or '—')
         row:tag('td'):wikitext(moveInfo.pp or '—')
     end
     end
      
 
     html:tag('div')
        :css('text-align', 'left')
        :css('background-color', '#' .. colors.light)
        :css('padding', '10px')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-top', 'none')
        :css('border-radius', '0 0 25px 25px')
        :wikitext([=[
* '''Emboldened''' moves denote that this Pokémon avails from a STAB bonus for it.
* ''Italicized'' moves are [[Broken_Moves_%26_Abilities|broken]]; see their individual pages for more information.
* Refer to the [[Move Tutors]] article for tutorship information for all moves hereon.
]=])
 
     return tostring(html) .. '<br clear="all">'
     return tostring(html) .. '<br clear="all">'
end
end
Line 449: Line 630:
     end
     end
      
      
    local colors = typeColor(frame, primaryType)
     local html = mw.html.create('div')
     local html = mw.html.create('div')
        :css('width', '77%')
        :css('margin', 'auto')
     local table = html:tag('table')
     local table = html:tag('table')
         :css('width', '77%')
        :addClass('sortable')
         :css('width', '100%')
         :css('text-align', 'center')
         :css('text-align', 'center')
         :css('margin', 'auto')
         :css('border-radius', '25px 25px 0 0')
         :css('border-radius', '25px')
         :css('border', '3px solid #' .. colors.dark)
         :css('border', '3px solid #' .. getBorderColor(primaryType))
         :css('border-bottom', 'none')
         :css('background-color', '#F5F5F5')
         :css('background-color', '#' .. colors.base)
         :css('padding', '7px')
         :css('padding', '7px')
   
 
     local header = table:tag('tr'):css('background-color', '#E0E0E0')
     local header = table:tag('tr')
     header:tag('th'):wikitext('Move'):css('width', '15%'):css('border-top-left-radius', '25px')
        :css('background-color', '#' .. colors.light)
     header:tag('th'):wikitext('Type'):css('width', '13%')
        :css('color', '#' .. colors.dark)
     header:tag('th'):wikitext('Category'):css('width', '14%')
     header:tag('th'):wikitext('Move'):css('width', '20%'):css('border-top-left-radius', '25px')
     header:tag('th'):wikitext('Base Power'):css('width', '15%')
     header:tag('th'):wikitext('Type'):css('width', '17%')
     header:tag('th'):wikitext('Accuracy'):css('width', '17%')
     header:tag('th'):wikitext('Category'):css('width', '16%')
     header:tag('th'):wikitext('PP'):css('width', '18%'):css('border-top-right-radius', '25px')
     header:tag('th'):wikitext('Base Power'):css('width', '17%')
   
     header:tag('th'):wikitext('Accuracy'):css('width', '15%')
     header:tag('th'):wikitext('PP'):css('width', '15%'):css('border-top-right-radius', '25px')
 
     for _, moveName in ipairs(moves) do
     for _, moveName in ipairs(moves) do
         local moveInfo = moveData[moveName] or {}
         local moveInfo = moveData[moveName] or {}
         local row = table:tag('tr'):css('background-color', '#FFFFFF')
         local row = table:tag('tr'):css('background-color', '#FFFFFF')
       
 
         local moveLink = '[[' .. moveName .. ']]'
         local moveLink = '[[' .. moveName .. ']]'
         if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
         if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
Line 479: Line 667:
         end
         end
         row:tag('td'):wikitext(moveLink)
         row:tag('td'):wikitext(moveLink)
       
 
         local typeColor = typeColors[moveInfo.type] or typeColors.Normal
         local moveTypeColors = typeColor(frame, moveInfo.type)
         row:tag('td')
         row:tag('td')
             :css('background-color', '#' .. typeColor.bg)
             :css('background-color', '#' .. moveTypeColors.base)
             :css('border', '2px solid #' .. typeColor.border)
             :css('border', '2px solid #' .. moveTypeColors.dark)
             :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')
             :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')
       
 
         local catColor = categoryColors[moveInfo.category] or categoryColors.Status
         local moveCatColors = categoryColor(frame, moveInfo.category)
         row:tag('td')
         row:tag('td')
             :css('background-color', '#' .. catColor.bg)
             :css('background-color', '#' .. moveCatColors.base)
             :css('border', '2px solid #' .. catColor.border)
             :css('border', '2px solid #' .. moveCatColors.dark)
             :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')
             :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')
       
 
         row:tag('td'):wikitext(moveInfo.power or '—')
         row:tag('td'):wikitext(moveInfo.power or '—')
         row:tag('td'):wikitext(moveInfo.accuracy or '—')
         row:tag('td'):wikitext(moveInfo.accuracy or '—')
         row:tag('td'):wikitext(moveInfo.pp or '—')
         row:tag('td'):wikitext(moveInfo.pp or '—')
     end
     end
   
 
     table:tag('tr'):tag('td'):attr('colspan', '6')
     html:tag('div')
         :css('text-align', 'left')
         :css('text-align', 'left')
         :css('background-color', '#E0E0E0')
         :css('background-color', '#' .. colors.light)
         :css('padding', '10px')
         :css('padding', '10px')
         :css('border-radius', '1px 1px 25px 25px')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-top', 'none')
         :css('border-radius', '0 0 25px 25px')
         :wikitext([=[
         :wikitext([=[
* '''Emboldened''' moves denote that this Pokémon avails from a STAB bonus for it.
* '''Emboldened''' moves denote that this Pokémon avails from a STAB bonus for it.
Line 509: Line 699:
      
      
     return tostring(html) .. '<br clear="all">'
     return tostring(html) .. '<br clear="all">'
end
local function renderEmptyNotice(frame, primaryType, radius, message)
    local colors = typeColor(frame, primaryType)
    local html = mw.html.create('div')
        :css('width', '77%')
        :css('margin', 'auto')
        :css('text-align', 'center')
        :css('background-color', '#' .. colors.light)
        :css('padding', '20px 10px')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-radius', radius)
        :wikitext("''" .. message .. "''")
    return tostring(html) .. '<br clear="all">'
end
local function hyphenSpaceVariant(s)
    if mw.ustring.find(s, '-', 1, true) then
        return (mw.ustring.gsub(s, '%-', ' '))
    end
    return (mw.ustring.gsub(s, ' ', '-'))
end
local function resolveType(pokemonName, type1Override, type2Override)
    if type1Override ~= '' then
        return type1Override, type2Override
    end
    local typeData = loadPokemonTypes()
    local pt = typeData[pokemonName]
        or typeData[sentenceCase(pokemonName)]
        or typeData[hyphenSpaceVariant(pokemonName)]
    if pt then
        return pt.type1 or '', pt.type2 or ''
    end
    return '', ''
end
end


Line 514: Line 739:
     local args        = getArgs(frame)
     local args        = getArgs(frame)
     local pokemonName = args[1] or args.pokemon or ''
     local pokemonName = args[1] or args.pokemon or ''
    local originalName = pokemonName


     if pokemonName == '' then
     if pokemonName == '' then
Line 519: Line 745:
     end
     end


     local primaryType  = args.type1 or ''
     primeLearnsetCache({ pokemonName })
    local secondaryType = args.type2 or ''


     if primaryType == '' then
    local poke, lsErr, resolvedName = resolvePagePokemon(pokemonName)
         local typeData = loadPokemonTypes()
     if not poke then
        local pt = typeData[pokemonName]
         return "<span style='color:red'>Data load error: " .. (lsErr or 'unknown') .. "</span>"
        if pt then
    end
            primaryType   = pt.type1 or ''
    pokemonName = resolvedName
            secondaryType = pt.type2 or ''
 
         end
    local primaryType, secondaryType = resolveType(originalName, args.type1 or '', args.type2 or '')
    if primaryType == '' and originalName ~= pokemonName then
         primaryType, secondaryType = resolveType(pokemonName, '', '')
     end
     end


     local function callRender(fn)
     local function callRender(fn, name, type1, type2)
         return fn(frame:newChild({
         return fn(frame:newChild({
             title = frame:getTitle(),
             title = frame:getTitle(),
             args  = { pokemonName, type1 = primaryType, type2 = secondaryType },
             args  = { name, type1 = type1, type2 = type2 },
         }))
         }))
     end
     end


     local poke, lsErr = getCachedLearnset(pokemonName)
     local formCandidates = {}
    if not poke then
    for _, form in ipairs(FORM_SUFFIXES) do
        return "<span style='color:red'>Data load error: " .. (lsErr or 'unknown') .. "</span>"
        local candidateName
        if form.learnsetStyle == 'prefix-space' then
            candidateName = form.suffix .. ' ' .. pokemonName
        else
            candidateName = pokemonName .. '-' .. form.suffix
        end
        table.insert(formCandidates, { form = form, candidateName = candidateName })
    end
 
    do
        local names = {}
        for _, fc in ipairs(formCandidates) do
            table.insert(names, fc.candidateName)
        end
        primeLearnsetCache(names)
    end
 
    local forms = {}
    for _, fc in ipairs(formCandidates) do
        local form, candidateName = fc.form, fc.candidateName
 
        local candidatePoke = getCachedLearnset(candidateName)
        if candidatePoke then
            local rawListName
            if form.rawList == 'suffix' then
                rawListName = candidateName
            elseif form.rawList == 'prefix-hyphen' then
                rawListName = form.label .. '-' .. pokemonName
            elseif form.rawList == 'suffix-label' then
                rawListName = pokemonName .. '-' .. form.label
            elseif form.rawList == 'suffix-space' then
                rawListName = pokemonName .. ' ' .. form.label
            else
                rawListName = form.label .. ' ' .. pokemonName
            end
 
            local t1, t2 = resolveType(rawListName, '', '')
            table.insert(forms, {
                name = form.label,
                fullName = candidateName,
                displayName = rawListName,
                poke = candidatePoke,
                type1 = t1,
                type2 = t2,
                baseLabel = form.baseLabel,
            })
        end
     end
     end


     local out = {}
     local sectionDefs = {
        { heading = 'Level-up', plain = 'Level-up', radius = '15px', render = p.renderLevelUp,
          hasData = function(pk) return #(pk.level_up or {}) > 0 end },
        { heading = '[[TMs and HMs]]', plain = 'TMs and HMs', radius = '25px', render = p.renderTMsHMs,
          hasData = function(pk) return #(pk.tms or {}) > 0 or #(pk.hms or {}) > 0 end },
        { heading = '[[Move Tutors]]', plain = 'Move Tutors', radius = '25px', render = p.renderMoveTutors,
          hasData = function(pk) return #(pk.move_tutors or {}) > 0 end },
        { heading = '[[Egg Moves]]', plain = 'Egg Moves', radius = '25px', render = p.renderEggMoves,
          hasData = function(pk) return #(pk.egg_moves or {}) > 0 end },
    }
 
    local baseLabel = 'Normal'
    for _, f in ipairs(forms) do
        if f.baseLabel then
            baseLabel = f.baseLabel
            break
        end
    end
    local baseDisplayName = (baseLabel ~= 'Normal') and (pokemonName .. ' ' .. baseLabel) or pokemonName


     if #(poke.level_up or {}) > 0 then
     local allParticipants = { { label = baseLabel, name = pokemonName, displayName = baseDisplayName, type1 = primaryType, type2 = secondaryType, poke = poke, isBase = true } }
         table.insert(out, "== Level-Up Moves ==")
    for _, f in ipairs(forms) do
        table.insert(out, callRender(p.renderLevelUp))
         table.insert(allParticipants, { label = f.name, name = f.fullName, displayName = f.displayName, type1 = f.type1, type2 = f.type2, poke = f.poke })
     end
     end


     if #(poke.tms or {}) > 0 or #(poke.hms or {}) > 0 then
     local sections = {}
         table.insert(out, "== TM/HM Moves ==")
    local usedTabs = false
        table.insert(out, callRender(p.renderTMsHMs))
 
    for _, def in ipairs(sectionDefs) do
        local anyHasData = false
        for _, part in ipairs(allParticipants) do
            if def.hasData(part.poke) then
                anyHasData = true
                break
            end
         end
 
        if anyHasData then
            table.insert(sections, "===" .. def.heading .. "===")
 
            if #allParticipants == 1 then
                local part = allParticipants[1]
                table.insert(sections, callRender(def.render, part.name, part.type1, part.type2))
            else
                usedTabs = true
 
                local tabHeaders, infoBoxes = '', ''
                for _, part in ipairs(allParticipants) do
                    tabHeaders = tabHeaders .. "{{Tabs:MultiformTab|Form=" .. part.label .. "}}"
 
                    local box
                    if def.hasData(part.poke) then
                        box = callRender(def.render, part.name, part.type1, part.type2)
                    else
                        box = renderEmptyNotice(frame, part.type1, def.radius,
                            'No ' .. def.plain .. ' data for ' .. (part.displayName or part.label) .. '.')
                    end
 
                    if part.isBase then
                        infoBoxes = infoBoxes .. "{{Tabs:MultiformTableAdditional|Table=" .. box .. "}}"
                    else
                        infoBoxes = infoBoxes .. "{{Tabs:MultiformTableAdditional|Form=" .. part.label .. "|Table=" .. box .. "}}"
                    end
                end
 
                table.insert(sections,
                    "{{TabbedInfoBoxes|Width=85|Class=|TabHeaders=" .. tabHeaders .. "|InfoBoxes=\n"
                    .. infoBoxes .. "\n}}")
            end
        end
     end
     end


     if #(poke.move_tutors or {}) > 0 then
     if #sections == 0 then
         table.insert(out, "== Move Tutor Moves ==")
         return ''
        table.insert(out, callRender(p.renderMoveTutors))
     end
     end


     if #(poke.egg_moves or {}) > 0 then
     local output = "==Moves==\n" .. table.concat(sections, '\n')
        table.insert(out, "== Egg Moves ==")
 
        table.insert(out, callRender(p.renderEggMoves))
    if usedTabs then
        return frame:preprocess(output)
     end
     end


     return table.concat(out, '\n')
     return output
end
end


return p
return p

Latest revision as of 10:09, 11 July 2026

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

local p = {}

local function getArgs(frame)
    local args = {}
    local parent = frame:getParent()
    
    if parent then
        for k, v in pairs(parent.args) do
            if v ~= '' then
                args[k] = v
            end
        end
    end
    
    for k, v in pairs(frame.args) do
        if v ~= '' then
            args[k] = v
        end
    end
    
    return args
end

local moveDataCache = nil

local function blankToNil(v)
    if v == nil or v == '' then
        return nil
    end
    return v
end

local function loadMoveData()
    if moveDataCache then
        return moveDataCache
    end
    
    local csvData = mw.ext.externalData.getExternalData({
        url = 'https://wiki.pokemonrevolution.net/index.php?title=Special:GetData/RawMovesList',
        format = 'CSV with header',
        data = {
            Name = 'Name',
            Type = 'Type',
            Category = 'Category',
            Power = 'Power',
            Accuracy = 'Accuracy',
            PP = 'PP',
            Broken = 'Broken',
            Typeless = 'Typeless',
            Priority = 'Priority',
            TM = 'TM',
            ['Move Tutor'] = 'Move Tutor',
            Egg = 'Egg',
        },
    })

    moveDataCache = {}
    if csvData then
        for _, row in ipairs(csvData) do
            moveDataCache[row.Name] = {
                type = blankToNil(row.Type) or 'Normal',
                category = blankToNil(row.Category) or 'Status',
                power = blankToNil(row.Power),
                accuracy = blankToNil(row.Accuracy),
                pp = blankToNil(row.PP),
                broken = row.Broken or 'N',
                typeless = row.Typeless or 'N',
                priority = blankToNil(row.Priority) or '0',
                tm = blankToNil(row.TM),
                move_tutor = blankToNil(row['Move Tutor']),
                egg = blankToNil(row.Egg)
            }
        end
    end

    return moveDataCache
end

local function loadLearnset(pokemonName)
    local jsonPath = '$[' .. mw.text.jsonEncode(pokemonName) .. ']'
    local ok, result, errors = pcall(function()
        return mw.ext.externalData.getFileData({
            source = 'data',
            ['file name'] = 'Learnsets.json',
            format = 'JSON',
            ['use jsonpath'] = true,
            data = { poke = jsonPath },
        })
    end)
    if not ok then
        return nil, 'Lua error: ' .. tostring(result)
    end
    if errors and #errors > 0 then
        return nil, 'ExternalData error: ' .. table.concat(errors, ' | ')
    end
    local raw = result and result.poke
    if type(raw) ~= 'table' then
        return nil, 'No learnset data found for ' .. pokemonName
    end
    return raw, nil
end

local function sentenceCase(s)
    local first = mw.ustring.sub(s, 1, 1)
    local rest = mw.ustring.sub(s, 2)
    return mw.ustring.upper(first) .. mw.ustring.lower(rest)
end

local FORM_SUFFIXES = {
    { suffix = 'Alolan', label = 'Alolan', rawList = 'prefix' },
    { suffix = 'Galar', label = 'Galarian', rawList = 'prefix' },
    { suffix = 'Hisui', label = 'Hisuian', rawList = 'prefix' },
    { suffix = 'Paldea', label = 'Paldean', rawList = 'prefix' },
    { suffix = 'Sandy', label = 'Sandy', rawList = 'suffix' },
    { suffix = 'Trash', label = 'Trash', rawList = 'suffix' },
    { suffix = 'Sky', label = 'Sky', rawList = 'suffix' },
    { suffix = 'Fan', label = 'Fan', rawList = 'suffix' },
    { suffix = 'Frost', label = 'Frost', rawList = 'suffix' },
    { suffix = 'Heat', label = 'Heat', rawList = 'suffix' },
    { suffix = 'Mow', label = 'Mow', rawList = 'suffix' },
    { suffix = 'Wash', label = 'Wash', rawList = 'suffix' },
    { suffix = 'Ash', label = 'Ash', learnsetStyle = 'prefix-space', rawList = 'prefix-hyphen' },
    { suffix = 'Bloodmoon', label = 'Bloodmoon', rawList = 'suffix' },
    { suffix = 'Low-Key', label = 'Low Key', rawList = 'suffix-label' },
    { suffix = 'F', label = 'Female', rawList = 'suffix-space', baseLabel = 'Male' },
    { suffix = 'Blue-Striped', label = 'Blue-Striped', rawList = 'suffix-label' },
    { suffix = 'White-Striped', label = 'White-Striped', rawList = 'suffix-label' },
    { suffix = 'Rainy', label = 'Rainy', rawList = 'suffix-label' },
    { suffix = 'Snowy', label = 'Snowy', rawList = 'suffix-label' },
    { suffix = 'Sunny', label = 'Sunny', rawList = 'suffix-label' },
    { suffix = 'Sunshine', label = 'Sunshine', rawList = 'suffix-label' },
    { suffix = 'Attack', label = 'Attack', rawList = 'suffix-label' },
    { suffix = 'Defense', label = 'Defense', rawList = 'suffix-label' },
    { suffix = 'Speed', label = 'Speed', rawList = 'suffix-label' },
    { suffix = 'Unbound', label = 'Unbound', rawList = 'suffix-label' },
    { suffix = 'Black', label = 'Black', rawList = 'suffix-label' },
    { suffix = 'White', label = 'White', rawList = 'suffix-label' },
    { suffix = 'Dusk', label = 'Dusk', rawList = 'suffix-label' },
    { suffix = 'Midnight', label = 'Midnight', rawList = 'suffix-label' },
    { suffix = 'Pirouette', label = 'Pirouette', rawList = 'suffix-label' },
    { suffix = 'Yellow', label = 'Yellow', rawList = 'suffix-label' },
    { suffix = 'School', label = 'School', rawList = 'suffix-label' },
    { suffix = 'Crowned', label = 'Crowned', rawList = 'suffix-label' },
}
local STRIP_SUFFIXES = {
    'Alolan', 'Galar', 'Hisui', 'Paldea', 'Sandy', 'Trash', 'Plant', 'Amped', 'Male',
    'Ice-Face', 'Midday', 'Solo',
}

local function stripKnownFormSuffix(name)
    for _, suffix in ipairs(STRIP_SUFFIXES) do
        local tail = '-' .. suffix
        if mw.ustring.sub(name, -mw.ustring.len(tail)) == tail then
            return mw.ustring.sub(name, 1, -mw.ustring.len(tail) - 1)
        end
    end
    return nil
end

local learnsetCache = {}
local learnsetErrorCache = {}

local function primeLearnsetCache(names)
    local uniqueNames, seen = {}, {}
    for _, name in ipairs(names) do
        if not seen[name] and learnsetCache[name] == nil and learnsetErrorCache[name] == nil then
            seen[name] = true
            table.insert(uniqueNames, name)
        end
    end
    if #uniqueNames == 0 then
        return
    end

    local dataSpec = {}
    local keyToName = {}
    for i, name in ipairs(uniqueNames) do
        local key = 'k' .. i
        dataSpec[key] = '$[' .. mw.text.jsonEncode(name) .. ']'
        keyToName[key] = name
    end

    local ok, result, errors = pcall(function()
        return mw.ext.externalData.getFileData({
            source = 'data',
            ['file name'] = 'Learnsets.json',
            format = 'JSON',
            ['use jsonpath'] = true,
            data = dataSpec,
        })
    end)

    if not ok then
        local msg = 'Lua error: ' .. tostring(result)
        for _, name in ipairs(uniqueNames) do
            learnsetErrorCache[name] = msg
        end
        return
    end
    if errors and #errors > 0 then
        local msg = 'ExternalData error: ' .. table.concat(errors, ' | ')
        for _, name in ipairs(uniqueNames) do
            learnsetErrorCache[name] = msg
        end
        return
    end

    for key, name in pairs(keyToName) do
        local val = result and result[key]
        if type(val) == 'table' then
            learnsetCache[name] = val
        else
            learnsetErrorCache[name] = 'No learnset data found for ' .. name
        end
    end
end

local function getCachedLearnset(pokemonName)
    if learnsetCache[pokemonName] == nil and learnsetErrorCache[pokemonName] == nil then
        local poke, err = loadLearnset(pokemonName)

        if not poke then
            local altName = sentenceCase(pokemonName)
            if altName ~= pokemonName then
                local altPoke = loadLearnset(altName)
                if altPoke then
                    poke, err = altPoke, nil
                end
            end
        end

        learnsetCache[pokemonName], learnsetErrorCache[pokemonName] = poke, err
    end
    return learnsetCache[pokemonName], learnsetErrorCache[pokemonName]
end

local function resolvePagePokemon(pokemonName)
    local poke, err = getCachedLearnset(pokemonName)
    if poke then
        return poke, nil, pokemonName
    end

    local baseName = stripKnownFormSuffix(pokemonName)
    if baseName then
        local basePoke, baseErr = getCachedLearnset(baseName)
        if basePoke then
            return basePoke, nil, baseName
        end
    end

    return nil, err, pokemonName
end

local pokemonTypeCache = nil

local function loadPokemonTypes()
    if pokemonTypeCache then return pokemonTypeCache end

    local csvData = mw.ext.externalData.getExternalData({
        url    = 'https://wiki.pokemonrevolution.net/index.php?title=Special:GetData/PokemonRawList',
        format = 'CSV with header',
        data = {
            Name = 'Name',
            Type1 = 'Type1',
            Type2 = 'Type2',
        },
    })

    pokemonTypeCache = {}
    if csvData then
        for _, row in ipairs(csvData) do
            pokemonTypeCache[row.Name] = {
                type1 = row.Type1 or '',
                type2 = row.Type2 or '',
            }
        end
    end
    return pokemonTypeCache
end

local typeColorCache = {}
local function typeColor(frame, pokeType)
    pokeType = (pokeType and pokeType ~= '') and pokeType or 'Normal'
    if not typeColorCache[pokeType] then
        typeColorCache[pokeType] = {
            light = frame:expandTemplate{ title = pokeType .. ' color light' },
            base = frame:expandTemplate{ title = pokeType .. ' color' },
            dark = frame:expandTemplate{ title = pokeType .. ' color dark' },
        }
    end
    return typeColorCache[pokeType]
end

local categoryColorCache = {}
local function categoryColor(frame, category)
    category = (category and category ~= '') and category or 'Status'
    if not categoryColorCache[category] then
        categoryColorCache[category] = {
            base = frame:expandTemplate{ title = category .. ' color' },
            dark = frame:expandTemplate{ title = category .. ' color dark' },
        }
    end
    return categoryColorCache[category]
end

local function isSTAB(moveType, primaryType, secondaryType)
    return moveType == primaryType or moveType == secondaryType
end

local function formatItemNumber(label, num)
    num = tonumber(num)
    if not num then
        return '—'
    end
    return label .. string.format('%02d', num)
end

function p.renderLevelUp(frame)
    local args = getArgs(frame)
    local pokemonName = args[1] or args.pokemon
    local primaryType = args.type1 or ""
    local secondaryType = args.type2 or ""
    
    local pokemon, lsErr = getCachedLearnset(pokemonName)
    local moveData = loadMoveData()

    if not pokemon then
        return "<span style='color:red'>Data load error: " .. (lsErr or 'unknown') .. "</span>"
    end

    local moves = pokemon.level_up or {}
    local colors = typeColor(frame, primaryType)

    local html = mw.html.create('div')
        :css('width', '77%')
        :css('margin', 'auto')
    local table = html:tag('table')
        :addClass('sortable')
        :css('width', '100%')
        :css('text-align', 'center')
        :css('border-radius', '15px 15px 0 0')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-bottom', 'none')
        :css('background-color', '#' .. colors.base)
        :css('padding', '7px')

        local header = table:tag('tr')
        :css('background-color', '#' .. colors.light)
        :css('color', '#' .. colors.dark)
    header:tag('th'):wikitext('Level'):css('width', '10%'):css('border-radius', '15px 1px 1px 1px')
    header:tag('th'):wikitext('Move'):css('width', '15%')
    header:tag('th'):wikitext('Type'):css('width', '17%')
    header:tag('th'):wikitext('Category'):css('width', '16%')
    header:tag('th'):wikitext('Base Power'):css('width', '17%')
    header:tag('th'):wikitext('Accuracy'):css('width', '15%')
    header:tag('th'):wikitext('PP'):css('width', '10%'):css('border-radius', '1px 15px 1px 1px')

        for _, move in ipairs(moves) do
        local moveName = move.move
        local level = move.level
        local moveInfo = moveData[moveName] or {}

        local row = table:tag('tr'):css('background-color', '#FFFFFF')
        row:tag('td'):wikitext(tostring(level))

        local moveLink = '[[' .. moveName .. ']]'
        if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
            moveLink = "'''" .. moveLink .. "'''"
        end
        if moveInfo.broken == "Y" then
            moveLink = "''" .. moveLink .. "''"
        end
        row:tag('td'):wikitext(moveLink)

        local moveTypeColors = typeColor(frame, moveInfo.type)
        row:tag('td')
            :css('background-color', '#' .. moveTypeColors.base)
            :css('border', '2px solid #' .. moveTypeColors.dark)
            :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')

        local moveCatColors = categoryColor(frame, moveInfo.category)
        row:tag('td')
            :css('background-color', '#' .. moveCatColors.base)
            :css('border', '2px solid #' .. moveCatColors.dark)
            :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')

        row:tag('td'):wikitext(moveInfo.power or '—')
        row:tag('td'):wikitext(moveInfo.accuracy or '—')
        row:tag('td'):wikitext(moveInfo.pp or '—')
    end

    html:tag('div')
        :css('text-align', 'left')
        :css('background-color', '#' .. colors.light)
        :css('padding', '10px')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-top', 'none')
        :css('border-radius', '0 0 15px 15px')
        :wikitext([=[
* '''Emboldened''' moves denote this Pokémon avails from a STAB bonus for it.
* ''Italicized'' moves are [[Broken_Moves_%26_Abilities|broken]]; see their individual pages for more information.
* All moves subsumed on this list are relearnable via the [[Move Relearner]]s if lost.
]=])
    
    return tostring(html) .. '<br clear="all">'
end

function p.renderTMsHMs(frame)
    local args = getArgs(frame)
    local pokemonName = args[1] or args.pokemon
    local primaryType = args.type1 or ""
    local secondaryType = args.type2 or ""
    
    local pokemon = getCachedLearnset(pokemonName)
    local moveData = loadMoveData()

    if not pokemon then return '' end

    local tms = pokemon.tms or {}
    local hms = pokemon.hms or {}
    local colors = typeColor(frame, primaryType)

    local html = mw.html.create('div')
        :css('width', '77%')
        :css('margin', 'auto')
    local table = html:tag('table')
        :addClass('sortable')
        :css('width', '100%')
        :css('text-align', 'center')
        :css('border-radius', '25px 25px 0 0')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-bottom', 'none')
        :css('background-color', '#' .. colors.base)
        :css('padding', '7px')

    local header = table:tag('tr')
        :css('background-color', '#' .. colors.light)
        :css('color', '#' .. colors.dark)
    header:tag('th'):attr('colspan', '2'):wikitext('TM'):css('width', '10%'):css('border-top-left-radius', '25px')
    header:tag('th'):wikitext('Move'):css('width', '15%')
    header:tag('th'):wikitext('Type'):css('width', '17%')
    header:tag('th'):wikitext('Category'):css('width', '16%')
    header:tag('th'):wikitext('Base Power'):css('width', '17%')
    header:tag('th'):wikitext('Accuracy'):css('width', '15%')
    header:tag('th'):wikitext('PP'):css('width', '10%'):css('border-top-right-radius', '25px')

    for _, moveName in ipairs(tms) do
        local moveInfo = moveData[moveName] or {}
        local row = table:tag('tr'):css('background-color', '#FFFFFF')

        row:tag('td'):attr('colspan', '2'):wikitext(formatItemNumber('TM', moveInfo.tm))

        local moveLink = '[[' .. moveName .. ']]'
        if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
            moveLink = "'''" .. moveLink .. "'''"
        end
        if moveInfo.broken == "Y" then
            moveLink = "''" .. moveLink .. "''"
        end
        row:tag('td'):wikitext(moveLink)

        local moveTypeColors = typeColor(frame, moveInfo.type)
        row:tag('td')
            :css('background-color', '#' .. moveTypeColors.base)
            :css('border', '2px solid #' .. moveTypeColors.dark)
            :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')

        local moveCatColors = categoryColor(frame, moveInfo.category)
        row:tag('td')
            :css('background-color', '#' .. moveCatColors.base)
            :css('border', '2px solid #' .. moveCatColors.dark)
            :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')

        row:tag('td'):wikitext(moveInfo.power or '—')
        row:tag('td'):wikitext(moveInfo.accuracy or '—')
        row:tag('td'):wikitext(moveInfo.pp or '—')
    end

    for _, moveName in ipairs(hms) do
        local moveInfo = moveData[moveName] or {}
        local row = table:tag('tr'):css('background-color', '#FFFFCC')

        row:tag('td'):attr('colspan', '2'):wikitext(formatItemNumber('HM', moveInfo.tm)):css('font-weight', 'bold')

        local moveLink = '[[' .. moveName .. ']]'
        if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
            moveLink = "'''" .. moveLink .. "'''"
        end
        if moveInfo.broken == "Y" then
            moveLink = "''" .. moveLink .. "''"
        end
        row:tag('td'):wikitext(moveLink)

        local moveTypeColors = typeColor(frame, moveInfo.type)
        row:tag('td')
            :css('background-color', '#' .. moveTypeColors.base)
            :css('border', '2px solid #' .. moveTypeColors.dark)
            :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')

        local moveCatColors = categoryColor(frame, moveInfo.category)
        row:tag('td')
            :css('background-color', '#' .. moveCatColors.base)
            :css('border', '2px solid #' .. moveCatColors.dark)
            :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')

        row:tag('td'):wikitext(moveInfo.power or '—')
        row:tag('td'):wikitext(moveInfo.accuracy or '—')
        row:tag('td'):wikitext(moveInfo.pp or '—')
    end

    html:tag('div')
        :css('text-align', 'left')
        :css('background-color', '#' .. colors.light)
        :css('padding', '10px')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-top', 'none')
        :css('border-radius', '0 0 25px 25px')
        :wikitext([=[
* '''Emboldened''' moves denote that this Pokémon avails from a STAB bonus for it.
* ''Italicized'' moves are [[Broken_Moves_%26_Abilities|broken]]; see their individual pages for more information.
* Refer to [[TMs and HMs]] for information on each move's obtainability.
]=])
    
    return tostring(html) .. '<br clear="all">'
end

function p.renderMoveTutors(frame)
    local args = getArgs(frame)
    local pokemonName = args[1] or args.pokemon
    local primaryType = args.type1 or ""
    local secondaryType = args.type2 or ""
    
    local pokemon = getCachedLearnset(pokemonName)
    local moveData = loadMoveData()

    if not pokemon then return '' end

    local moves = pokemon.move_tutors or {}
    
    if #moves == 0 then
        return ""
    end
    
    local colors = typeColor(frame, primaryType)

    local html = mw.html.create('div')
        :css('width', '77%')
        :css('margin', 'auto')
    local table = html:tag('table')
        :addClass('sortable')
        :css('width', '100%')
        :css('text-align', 'center')
        :css('border-radius', '25px 25px 0 0')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-bottom', 'none')
        :css('background-color', '#' .. colors.base)
        :css('padding', '7px')

    local header = table:tag('tr')
        :css('background-color', '#' .. colors.light)
        :css('color', '#' .. colors.dark)
    header:tag('th'):wikitext('Move'):css('width', '20%'):css('border-top-left-radius', '25px')
    header:tag('th'):wikitext('Type'):css('width', '17%')
    header:tag('th'):wikitext('Category'):css('width', '16%')
    header:tag('th'):wikitext('Base Power'):css('width', '17%')
    header:tag('th'):wikitext('Accuracy'):css('width', '15%')
    header:tag('th'):wikitext('PP'):css('width', '15%'):css('border-top-right-radius', '25px')

    for _, moveName in ipairs(moves) do
        local moveInfo = moveData[moveName] or {}
        local row = table:tag('tr'):css('background-color', '#FFFFFF')

        local moveLink = '[[' .. moveName .. ']]'
        if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
            moveLink = "'''" .. moveLink .. "'''"
        end
        if moveInfo.broken == "Y" then
            moveLink = "''" .. moveLink .. "''"
        end
        row:tag('td'):wikitext(moveLink)

        local moveTypeColors = typeColor(frame, moveInfo.type)
        row:tag('td')
            :css('background-color', '#' .. moveTypeColors.base)
            :css('border', '2px solid #' .. moveTypeColors.dark)
            :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')

        local moveCatColors = categoryColor(frame, moveInfo.category)
        row:tag('td')
            :css('background-color', '#' .. moveCatColors.base)
            :css('border', '2px solid #' .. moveCatColors.dark)
            :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')

        row:tag('td'):wikitext(moveInfo.power or '—')
        row:tag('td'):wikitext(moveInfo.accuracy or '—')
        row:tag('td'):wikitext(moveInfo.pp or '—')
    end

    html:tag('div')
        :css('text-align', 'left')
        :css('background-color', '#' .. colors.light)
        :css('padding', '10px')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-top', 'none')
        :css('border-radius', '0 0 25px 25px')
        :wikitext([=[
* '''Emboldened''' moves denote that this Pokémon avails from a STAB bonus for it.
* ''Italicized'' moves are [[Broken_Moves_%26_Abilities|broken]]; see their individual pages for more information.
* Refer to the [[Move Tutors]] article for tutorship information for all moves hereon.
]=])

    return tostring(html) .. '<br clear="all">'
end

function p.renderEggMoves(frame)
    local args = getArgs(frame)
    local pokemonName = args[1] or args.pokemon
    local primaryType = args.type1 or ""
    local secondaryType = args.type2 or ""
    
    local pokemon = getCachedLearnset(pokemonName)
    local moveData = loadMoveData()

    if not pokemon then return '' end

    local moves = pokemon.egg_moves or {}
    
    if #moves == 0 then
        return ""
    end
    
    local colors = typeColor(frame, primaryType)

    local html = mw.html.create('div')
        :css('width', '77%')
        :css('margin', 'auto')
    local table = html:tag('table')
        :addClass('sortable')
        :css('width', '100%')
        :css('text-align', 'center')
        :css('border-radius', '25px 25px 0 0')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-bottom', 'none')
        :css('background-color', '#' .. colors.base)
        :css('padding', '7px')

    local header = table:tag('tr')
        :css('background-color', '#' .. colors.light)
        :css('color', '#' .. colors.dark)
    header:tag('th'):wikitext('Move'):css('width', '20%'):css('border-top-left-radius', '25px')
    header:tag('th'):wikitext('Type'):css('width', '17%')
    header:tag('th'):wikitext('Category'):css('width', '16%')
    header:tag('th'):wikitext('Base Power'):css('width', '17%')
    header:tag('th'):wikitext('Accuracy'):css('width', '15%')
    header:tag('th'):wikitext('PP'):css('width', '15%'):css('border-top-right-radius', '25px')

    for _, moveName in ipairs(moves) do
        local moveInfo = moveData[moveName] or {}
        local row = table:tag('tr'):css('background-color', '#FFFFFF')

        local moveLink = '[[' .. moveName .. ']]'
        if isSTAB(moveInfo.type, primaryType, secondaryType) and moveInfo.category ~= "Status" then
            moveLink = "'''" .. moveLink .. "'''"
        end
        if moveInfo.broken == "Y" then
            moveLink = "''" .. moveLink .. "''"
        end
        row:tag('td'):wikitext(moveLink)

        local moveTypeColors = typeColor(frame, moveInfo.type)
        row:tag('td')
            :css('background-color', '#' .. moveTypeColors.base)
            :css('border', '2px solid #' .. moveTypeColors.dark)
            :wikitext('[[' .. (moveInfo.type or 'Normal') .. '_(type)|<span style="color:#FFFFFF;">' .. (moveInfo.type or 'Normal') .. '</span>]]')

        local moveCatColors = categoryColor(frame, moveInfo.category)
        row:tag('td')
            :css('background-color', '#' .. moveCatColors.base)
            :css('border', '2px solid #' .. moveCatColors.dark)
            :wikitext('[[' .. (moveInfo.category or 'Status') .. ' moves|<span style="color:#FFFFFF;">' .. (moveInfo.category or 'Status') .. '</span>]]')

        row:tag('td'):wikitext(moveInfo.power or '—')
        row:tag('td'):wikitext(moveInfo.accuracy or '—')
        row:tag('td'):wikitext(moveInfo.pp or '—')
    end

    html:tag('div')
        :css('text-align', 'left')
        :css('background-color', '#' .. colors.light)
        :css('padding', '10px')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-top', 'none')
        :css('border-radius', '0 0 25px 25px')
        :wikitext([=[
* '''Emboldened''' moves denote that this Pokémon avails from a STAB bonus for it.
* ''Italicized'' moves are [[Broken_Moves_%26_Abilities|broken]]; see their individual pages for more information.
* For more information on egg-move-tutors, refer to: [[Egg Moves]].
]=])
    
    return tostring(html) .. '<br clear="all">'
end

local function renderEmptyNotice(frame, primaryType, radius, message)
    local colors = typeColor(frame, primaryType)
    local html = mw.html.create('div')
        :css('width', '77%')
        :css('margin', 'auto')
        :css('text-align', 'center')
        :css('background-color', '#' .. colors.light)
        :css('padding', '20px 10px')
        :css('border', '3px solid #' .. colors.dark)
        :css('border-radius', radius)
        :wikitext("''" .. message .. "''")
    return tostring(html) .. '<br clear="all">'
end

local function hyphenSpaceVariant(s)
    if mw.ustring.find(s, '-', 1, true) then
        return (mw.ustring.gsub(s, '%-', ' '))
    end
    return (mw.ustring.gsub(s, ' ', '-'))
end

local function resolveType(pokemonName, type1Override, type2Override)
    if type1Override ~= '' then
        return type1Override, type2Override
    end
    local typeData = loadPokemonTypes()
    local pt = typeData[pokemonName]
        or typeData[sentenceCase(pokemonName)]
        or typeData[hyphenSpaceVariant(pokemonName)]
    if pt then
        return pt.type1 or '', pt.type2 or ''
    end
    return '', ''
end

function p.main(frame)
    local args        = getArgs(frame)
    local pokemonName = args[1] or args.pokemon or ''
    local originalName = pokemonName

    if pokemonName == '' then
        return "<span style='color:red'>Usage: {{Learnsets|PokemonName}}</span>"
    end

    primeLearnsetCache({ pokemonName })

    local poke, lsErr, resolvedName = resolvePagePokemon(pokemonName)
    if not poke then
        return "<span style='color:red'>Data load error: " .. (lsErr or 'unknown') .. "</span>"
    end
    pokemonName = resolvedName

    local primaryType, secondaryType = resolveType(originalName, args.type1 or '', args.type2 or '')
    if primaryType == '' and originalName ~= pokemonName then
        primaryType, secondaryType = resolveType(pokemonName, '', '')
    end

    local function callRender(fn, name, type1, type2)
        return fn(frame:newChild({
            title = frame:getTitle(),
            args  = { name, type1 = type1, type2 = type2 },
        }))
    end

    local formCandidates = {}
    for _, form in ipairs(FORM_SUFFIXES) do
        local candidateName
        if form.learnsetStyle == 'prefix-space' then
            candidateName = form.suffix .. ' ' .. pokemonName
        else
            candidateName = pokemonName .. '-' .. form.suffix
        end
        table.insert(formCandidates, { form = form, candidateName = candidateName })
    end

    do
        local names = {}
        for _, fc in ipairs(formCandidates) do
            table.insert(names, fc.candidateName)
        end
        primeLearnsetCache(names)
    end

    local forms = {}
    for _, fc in ipairs(formCandidates) do
        local form, candidateName = fc.form, fc.candidateName

        local candidatePoke = getCachedLearnset(candidateName)
        if candidatePoke then
            local rawListName
            if form.rawList == 'suffix' then
                rawListName = candidateName
            elseif form.rawList == 'prefix-hyphen' then
                rawListName = form.label .. '-' .. pokemonName
            elseif form.rawList == 'suffix-label' then
                rawListName = pokemonName .. '-' .. form.label
            elseif form.rawList == 'suffix-space' then
                rawListName = pokemonName .. ' ' .. form.label
            else
                rawListName = form.label .. ' ' .. pokemonName
            end

            local t1, t2 = resolveType(rawListName, '', '')
            table.insert(forms, {
                name = form.label,
                fullName = candidateName,
                displayName = rawListName,
                poke = candidatePoke,
                type1 = t1,
                type2 = t2,
                baseLabel = form.baseLabel,
            })
        end
    end

    local sectionDefs = {
        { heading = 'Level-up', plain = 'Level-up', radius = '15px', render = p.renderLevelUp,
          hasData = function(pk) return #(pk.level_up or {}) > 0 end },
        { heading = '[[TMs and HMs]]', plain = 'TMs and HMs', radius = '25px', render = p.renderTMsHMs,
          hasData = function(pk) return #(pk.tms or {}) > 0 or #(pk.hms or {}) > 0 end },
        { heading = '[[Move Tutors]]', plain = 'Move Tutors', radius = '25px', render = p.renderMoveTutors,
          hasData = function(pk) return #(pk.move_tutors or {}) > 0 end },
        { heading = '[[Egg Moves]]', plain = 'Egg Moves', radius = '25px', render = p.renderEggMoves,
          hasData = function(pk) return #(pk.egg_moves or {}) > 0 end },
    }

    local baseLabel = 'Normal'
    for _, f in ipairs(forms) do
        if f.baseLabel then
            baseLabel = f.baseLabel
            break
        end
    end
    local baseDisplayName = (baseLabel ~= 'Normal') and (pokemonName .. ' ' .. baseLabel) or pokemonName

    local allParticipants = { { label = baseLabel, name = pokemonName, displayName = baseDisplayName, type1 = primaryType, type2 = secondaryType, poke = poke, isBase = true } }
    for _, f in ipairs(forms) do
        table.insert(allParticipants, { label = f.name, name = f.fullName, displayName = f.displayName, type1 = f.type1, type2 = f.type2, poke = f.poke })
    end

    local sections = {}
    local usedTabs = false

    for _, def in ipairs(sectionDefs) do
        local anyHasData = false
        for _, part in ipairs(allParticipants) do
            if def.hasData(part.poke) then
                anyHasData = true
                break
            end
        end

        if anyHasData then
            table.insert(sections, "===" .. def.heading .. "===")

            if #allParticipants == 1 then
                local part = allParticipants[1]
                table.insert(sections, callRender(def.render, part.name, part.type1, part.type2))
            else
                usedTabs = true

                local tabHeaders, infoBoxes = '', ''
                for _, part in ipairs(allParticipants) do
                    tabHeaders = tabHeaders .. "{{Tabs:MultiformTab|Form=" .. part.label .. "}}"

                    local box
                    if def.hasData(part.poke) then
                        box = callRender(def.render, part.name, part.type1, part.type2)
                    else
                        box = renderEmptyNotice(frame, part.type1, def.radius,
                            'No ' .. def.plain .. ' data for ' .. (part.displayName or part.label) .. '.')
                    end

                    if part.isBase then
                        infoBoxes = infoBoxes .. "{{Tabs:MultiformTableAdditional|Table=" .. box .. "}}"
                    else
                        infoBoxes = infoBoxes .. "{{Tabs:MultiformTableAdditional|Form=" .. part.label .. "|Table=" .. box .. "}}"
                    end
                end

                table.insert(sections,
                    "{{TabbedInfoBoxes|Width=85|Class=|TabHeaders=" .. tabHeaders .. "|InfoBoxes=\n"
                    .. infoBoxes .. "\n}}")
            end
        end
    end

    if #sections == 0 then
        return ''
    end

    local output = "==Moves==\n" .. table.concat(sections, '\n')

    if usedTabs then
        return frame:preprocess(output)
    end

    return output
end

return p