Module:UnitData

From Fire Emblem Heroes Wiki
Jump to: navigation, search
Template-info.svg Documentation

This module provides functions to display unit data, store them into the wiki database, and automatically adjust unit data for derived maps. It is heavy optimized and designed to replace its template equivalent.

Usage[edit source]

The entry point of this module is simply main. Two different sets of parameters are accepted by the module, depending on whether unit data is defined or retrieved from the database.

Unit definitions[edit source]

To define units for a map, add the following to the map page itself:

{{#invoke:UnitData|main
|mapImage=<!-- map image, sometimes optional -->
|Normal=[
{unit=;pos=;rarity=;slot=;level=;stats=[hp;atk;spd;def;res];weapon=;refine=;assist=;special=;a=;b=;c=;seal=;accessory=;cooldown=;properties=;ai={turn=;group=;delay=;break_walls=;tether=};spawn={turn=;count=;target=;remain=;kills=};};
{unit=;pos=;rarity=;slot=;level=;stats=[hp;atk;spd;def;res];weapon=;assist=;special=;a=;b=;c=;seal=}; <!-- less verbose -->
<!-- other units -->
]
|Hard=[<!-- more units -->]
|Lunatic=[<!-- more units -->]
|<!-- other tabs -->
}}
  • mapImage: (Optional) Map image using Template:MapLayout or {{#invoke:MapLayout|map}}. Initial units should not be added, they are automatically populated by this module. The same map image is used for all unit tabs. This parameter is required if any unit specified in the module is a reinforcement unit.
  • globalai: (Optional) The default AI setting for units that do not have their own ai specifications. This is mostly useful for manually inputted unit data. Apart from the values accepted for unit definitions, the following string values expand to commonly used settings:
    • activeall: {turn=1}. This is also the default AI setting if this parameter is not given.
    • passivelinked: {group=1}
    • passivesingle: {}
  • no cargo: (Optional) If true, do not store any units into the Cargo database. Random units (see below) are never stored regardless of whether this parameter is set.
  • no ai: (Optional) If true, do not show the AI settings tables.
  • Other parameters: Any parameter name that isn't one of the parameters listed above or a derived map parameter is considered a tab name. The parameter value is an ObjectArg list of hashes defining the units for the Normal tab. The difficulty of the tab is automatically determined from the tab name.

Unlike the old templates, units no longer need to be defined in separate groups depending on their armies and reinforcement settings; the module automatically groups units and generates multiple unit tables as necessary.

Derived maps[edit source]

To display units for a derived map, use the following parameters instead:

{{#invoke:UnitData|main
|battle=
|derived=
|derivedMap=
|derivedTabs={Normal=Normal;Hard=Lunatic}
|mapImage=
|allyPos=
}}
  • battle: Battle number for the derived map, e.g. 1 for the first battle.
  • derived: One of the tags for derived maps. See below for a complete list of supported values.
  • derivedMap: (Optional) Page name of the base map where the derived units were originally defined. Defaults to the current page (it is possible to display derived unit stats on the same page they are defined, but this is no longer done).
  • derivedTabs: An ObjectArg hash which maps tab names from the original map to tab names of the derived map. In the example above, the Normal tab contains derived units from the original map at Normal difficulty, while the Lunatic tab contains derived units from Hard difficulty. The new tab names must be supported by the derived setting for unit promotion to occur.
  • mapImage: Map image using Template:MapLayout or {{#invoke:MapLayout|map}}. Initial units should not be added, they are automatically populated by this module. The same map image is used for all unit tabs.
  • allyPos: Comma-separated list of ally starting positions, same as in Template:MapLayout. On derived maps with reinforcements, these starting positions must be declared outside the mapImage parameter, so that they will be placed only in the infobox but not in reinforcement layouts.

List of derived map settings[edit source]

A list of values that can be used for the derived parameter, followed by the tab names supported by that setting.

Derived map settings
  • blessed_gardens: Normal, Hard, Lunatic, Infernal
  • blessed_grounds_even: Infernal
  • blessed_grounds_odd: Lunatic, Infernal
  • cc_paralogue_even1_double: Normal, Hard, Lunatic
  • cc_paralogue_even1_single: Normal, Hard, Lunatic
  • cc_paralogue_even1_single_cx019: Normal, Hard, Lunatic
  • cc_paralogue_even2_double: Normal, Hard, Lunatic
  • cc_paralogue_even2_single: Normal, Hard, Lunatic
  • cc_paralogue_even3_double: Normal, Hard, Lunatic
  • cc_paralogue_even3_single: Normal, Hard, Lunatic
  • cc_paralogue_even3_single_cx019: Normal, Hard, Lunatic
  • cc_paralogue_odd1_double: Normal, Hard, Lunatic
  • cc_paralogue_odd1_double_cx019: Normal, Hard, Lunatic
  • cc_paralogue_odd1_single: Normal, Hard, Lunatic
  • cc_paralogue_odd1_single_cx019: Normal, Hard, Lunatic
  • cc_paralogue_odd2_double: Normal, Hard, Lunatic
  • cc_paralogue_odd2_single: Normal, Hard, Lunatic
  • cc_paralogue_odd3_double: Normal, Hard, Lunatic
  • cc_paralogue_odd3_single: Normal, Hard, Lunatic
  • cc_paralogue_odd3_single_cx019: Normal, Hard, Lunatic
  • cc_story_even1_double: Normal, Hard, Lunatic
  • cc_story_even1_single: Normal, Hard, Lunatic
  • cc_story_even2_double: Normal, Hard, Lunatic
  • cc_story_even2_single: Normal, Hard, Lunatic
  • cc_story_even3_double: Normal, Hard, Lunatic
  • cc_story_even3_single: Normal, Hard, Lunatic
  • cc_story_even4_double: Normal, Hard, Lunatic
  • cc_story_even4_single: Normal, Hard, Lunatic
  • cc_story_even5_double: Normal, Hard, Lunatic
  • cc_story_even5_single: Normal, Hard, Lunatic
  • cc_story_odd1_double: Normal, Hard, Lunatic
  • cc_story_odd1_single: Normal, Hard, Lunatic
  • cc_story_odd2_double: Normal, Hard, Lunatic
  • cc_story_odd2_single: Normal, Hard, Lunatic
  • cc_story_odd3_double: Normal, Hard, Lunatic
  • cc_story_odd3_single: Normal, Hard, Lunatic
  • cc_story_odd4_double: Normal, Hard, Lunatic
  • cc_story_odd4_single: Normal, Hard, Lunatic
  • cc_story_odd5_double: Normal, Hard, Lunatic
  • cc_story_odd5_single: Normal, Hard, Lunatic
  • squad_assault: Lunatic

The tags have the following usage:

  • blessed_gardens is applied to all Blessed Gardens.
  • blessed_grounds_odd is applied to all odd-numbered Grounds (e.g. Grounds of Earth: Earth 1, Grounds of Fire: Fire 3), similarly for blessed_grounds_even.
  • cc_paralogue_odd* is applied to odd-numbered Paralogue chapters with corresponding part numbers. single is for single-chapter Chain Challenges, double is for double-chapter Chain Challenges. cc_paralogue_even* is for even-numbered chapters.
  • cc_story_odd* and cc_story_even* are used similarly but for story maps. Book number does not matter.
  • squad_assault is applied to all Squad Assault maps.

Unit definition[edit source]

Each hash inside a unit data list defines a single ally or enemy unit, corresponding to a single use of the old Template:UnitData. More precisely, each hash represents a spawn entry which may be used to produce multiple identical reinforcement units.

  • unit: Page name of the unit.
  • pos: Coordinate of the unit. Like Template:MapLayout, a1 corresponds to the bottom left corner.
  • rarity: Rarity of the unit.
  • level: Level of the unit. Levels above 40 can be obtained from internal map definitions, but all known values for 40+ levels can be deduced from the map mode alone.
  • slot: Internal slot order of the unit. The slot order 1 is assigned to the first unit defined in the map file, and increments by 1 for every other unit defined. On maps without reinforcements, this is equal to the slot index assigned to the spawned unit, and can be obtained by experimenting with the game AI.
  • stats: A 5-element array containing the base stats of the unit, excluding any stats coming from equipped skills and other skill adjustments such as Arena bonuses or blessings. This is not always equal to final stat subtracted by stat modifiers from skills because the game clamps final stats between 0 and 99, but such cases are extremely rare; consult the internal map definitions when in doubt.
  • weapon: Page name of the unit's equipped weapon. Use - to indicate absence of an equipped weapon.
  • refine: (Optional) Refine path of the unit's weapon. Must be one of atk, spd, def, res, skill1, or skill2.
  • assist, special, a, b, c, seal: Skill name of the unit's equipped Assist / Special / Passive A / Passive B / Passive C / Sacred Seal. Use - to indicate absence of an equipped skill.
  • accessory: (Optional) Page name of the unit's equipped accessory.
  • cooldown: (Optional) Initial Special cooldown count of the unit. Only needed for units with a non-default cooldown count (but the module does not check whether the given count is indeed less than the default count).
  • max_cooldown: (Optional) Maximum Special cooldown count of the unit. Only needed for units with a non-default maximum cooldown count.
  • properties: (Optional) Comma-separated list of properties applied to the unit. Currently the following properties are accepted:
    • is_ally: Indicates that the unit is an ally unit rather than an enemy unit. Ally units have their own sections separate from enemy units.
    • use_ally_stats: When used on an enemy unit that has both playable and enemy variants (e.g. Surtr Ruler of Flame Face FC.webp Surtr: Ruler of Flame), indicates that the playable variant should be used for stat calculation and skill promotion on derived maps.
    • use_enemy_stats: When used on an ally unit that has both playable and enemy variants, indicates that the enemy variant should be used for stat calculation and skill promotion on derived maps.
  • ai: (Optional) AI settings for the individual unit. If omitted, uses the globalai value of the module invocation, otherwise completely overrides that default setting (note that this parameter does not accept simple string values). It should be a hash with the following subfields, all optional:
    • turn: The first turn from which the unit starts moving on its own.
    • group: The AI group number of the unit; when any unit from a group is engaged, all units from that group start moving.
    • delay: The number of turns the unit may wait after its AI group is engaged but before the unit starts moving.
    • break_walls: Indicates the unit will destroy breakable terrain.
    • tether: Indicates the unit will return to its starting position if it has no actions to take. (Needs further investigation.)
  • spawn: (Optional) Spawn settings for the unit. If omitted, the unit appears in the initial setup of the map, otherwise this entry represents one or multiple reinforcements. It should be a hash with the following subfields, all optional:
    • turn: The first turn starting from which reinforcements appear.
    • count: The maximum number of reinforcement units to appear.
    • target: The page name of the target unit used to check for spawning.
    • remain: The maximum number of target units remaining on the map before reinforcements appear.
    • kills: The minimum number of target units to kill before reinforcements appear.
    • cond: Only for old special map pages where reinforcement settings can no longer be tested. If present, overrides the other setting fields and uses it as the reinforcement description in the map visual.

The module tracks pages containing unit definitions where any of the weapon, assist, special, a, b, c, or seal parameters are not given.

Random units[edit source]

On Tempest Trials only, random units can also be specified through this template. The unit definition takes a shorter format:

{pos=;rarity=;slot=;level=;ai=;spawn=;random={moves=;weapons=;staff=};};

The pos, rarity, slot, level, ai, and spawn fields share their meanings with non-random units. The random argument is a hash with the following subfields:

  • moves: (Optional) Move type of the unit. Omit to allow all move types.
  • weapons: (Optional) Weapon type of the unit, or any of the weapon class names such as "Ranged" or "Physical". Omit to allow all weapon types.
  • staff: (Optional) If explicitly set to false, excludes staff weapon types.

Known values for 40+ levels[edit source]

Map mode Difficulty Level
Story maps Lunatic 40 / 41 / 42 / 43 / 45
Paralogue maps Luantic 40 / 42 / 45
Grand / Bound / Legendary / Mythic Hero Battles Infernal 45
Abyssal 50
Tempest Trials Lunatic (7×) 40 / 41 / 42 / 43 / 44 / 45 / 45

Examples[edit source]

{{#invoke:UnitData|main
}}
local Util = require 'Module:Util'
local List = require 'Module:ListUtil'
local Hash = require 'Module:HashUtil'
local FEHStatUtil = require 'Module:FEHStatUtil'
local Tab = require 'Module:Tab'
local MapLayout = require 'Module:MapLayout'
local parseArgs = require 'Module:ObjectArg'.parse
local memoizer = require 'Module:Memoizer'.memoizer
local superimposeDiv = require 'Module:Superimpose'.div
local stripWikitext = require('Module:StripWikitext').main1
local escq = require 'Module:EscQ'.main1
local toboolean = require 'Module:Bool'.toboolean
local mf = Util.mf
local cargo = mw.ext.cargo

local STATS = {'HP', 'Atk', 'Spd', 'Def', 'Res'}
local SKILL_CATEGORIES = {'weapon', 'assist', 'special', 'a', 'b', 'c', 'seal'}
local DIFFICULTY_ORDER = Util.getDifficulties()
local EMPTY_STAT_MODIFIERS = {0, 0, 0, 0, 0}
local EMPTY_STRING = "-"
local EMPTY_TEXT_LONG = "—"
local UNKNOWN_TEXT = "?"
local ORDERING_MIXIN = List.ordering_mixin()



local memoizeTable = function (f)
	local mt = {__metatable = 'memoizeTable'}
	local done = false
	mt.__index = function (_, k)
		done = true
		mt.__index = f()
		return mt.__index[k]
	end
	mt.__pairs = function (_)
		if not done then
			mt.__index = f()
		end
		return pairs(mt.__index)
	end
	return setmetatable({}, mt)
end
local MOVE_TYPES = memoizeTable(function ()
	return Hash.from_ipairs(cargo.query("MoveTypes", "IconFile,Name,_pageName,WikiName,Sort", {groupBy = 'WikiName', limit = 100}), function (row)
		row.Sort = tonumber(row.Sort)
		return row.WikiName, row
	end)
end)
local WEAPON_TYPES = memoizeTable(function ()
	return Hash.from_ipairs(cargo.query("WeaponTypes", "IconFile,Name,_pageName,WikiName,Classes__full=Classes,Type,Sort", {groupBy = 'WikiName', limit = 100}), function (row)
		row.Sort = tonumber(row.Sort)
		row.Classes = mw.text.split(row.Classes, '%s*,%s*')
		return row.WikiName, row
	end)
end)

local memoizeUnits = memoizer(function (name)
	local result = cargo.query(
		'Units,UnitStats',
		[[Units._pageName=page,Units.WikiName=wikiname,Name=name,MoveType,WeaponType,Properties,
		  IF(Properties__full LIKE '%enemy%','1','')=isEnemy,IF(Properties__full LIKE '%generic%','1','')=isGeneric,
		  Lv1HP5,Lv1Atk5,Lv1Spd5,Lv1Def5,Lv1Res5,HPGR3,AtkGR3,SpdGR3,DefGR3,ResGR3]], {
			join = 'Units.WikiName=UnitStats.WikiName',
			where = ("'%s' IN (Units._pageName,IFNULL(CONCAT(Name,': ',Title),Name))"):format(escq(name)),
			limit = 2, -- ally + enemy
		})
	for _, v in ipairs(result) do
		v.isEnemy = v.isEnemy ~= ''
		v.isGeneric = v.isGeneric ~= ''
	end
	return result
end)

local memoizeUnitsWithWikiName = function ()
	memoizeUnits = memoizer(function (name)
		local result = cargo.query(
			'Units,UnitStats',
			[[Units._pageName=page,Units.WikiName=wikiname,Name=name,MoveType,WeaponType,Properties,
			  IF(Properties__full LIKE '%enemy%','1','')=isEnemy,IF(Properties__full LIKE '%generic%','1','')=isGeneric,
			  Lv1HP5,Lv1Atk5,Lv1Spd5,Lv1Def5,Lv1Res5,HPGR3,AtkGR3,SpdGR3,DefGR3,ResGR3]], {
				join = 'Units.WikiName=UnitStats.WikiName',
				where = ("Units.WikiName='%s'"):format(escq(name)),
				limit = 2, -- ally + enemy
			})
		for _, v in ipairs(result) do
			v.isEnemy = v.isEnemy ~= ''
			v.isGeneric = v.isGeneric ~= ''
		end
		return result
	end)
end

--[[ {default skill at 1*, ..., default skill at 5*} ]]
local memoizeUnitWeapons = memoizer(function (wikiname)
	local result = cargo.query('Units,UnitSkills,Skills', "skill,defaultRarity,skillPos", {
		join = 'Units.WikiName=UnitSkills.WikiName,UnitSkills.skill=Skills.WikiName',
		where = ("Units.WikiName='%s' AND Scategory='weapon'"):format(escq(wikiname)),
		limit = 10,
	})
	for _, v in ipairs(result) do
		v.defaultRarity = tonumber(v.defaultRarity)
		v.skillPos = tonumber(v.skillPos)
	end

	result = List.select(result, function (v) return v.defaultRarity ~= nil end)
	table.sort(result, function (x, y) return x.defaultRarity < y.defaultRarity end)
	return List.generate(5, function (rarity)
		local s = select(2, List.rfind_if(result, function (v) return v.defaultRarity <= rarity end))
		return s and s.skill or false
	end)
end)

local disableSkillDesc = false

local memoizeSkill = (function ()
	local DEFAULT_ICONS = {
		weapon = "Icon_Skill_Weapon.png",
		assist = "Icon_Skill_Assist.png",
		special = "Icon_Skill_Special.png",
		a = "Empty_Passive_Icon.png",
		b = "Empty_Passive_Icon.png",
		c = "Empty_Passive_Icon.png",
		seal = "Empty_Passive_Icon.png",
	}
	local MISSING_ICONS = {
		weapon = "Icon_Skill_Weapon.png",
		assist = "Icon_Skill_Assist.png",
		special = "Icon_Skill_Special.png",
		a = "Passive_No_Image.png",
		b = "Passive_No_Image.png",
		c = "Passive_No_Image.png",
		seal = "Passive_No_Image.png",
	}
	local CATEGORY_QUERIES = {
		weapon = "Scategory='weapon'",
		assist = "Scategory='assist'",
		special = "Scategory='special'",
		a = "Scategory='passivea'",
		b = "Scategory='passiveb'",
		c = "Scategory='passivec'",
		seal = "Scategory LIKE 'passive_' OR Scategory='sacredseal'",
	}

	return memoizer(function (skillName, category, refine)
		if Util.isNilOrEmpty(skillName) then
			return {ic = MISSING_ICONS[category], name = UNKNOWN_TEXT, StatModifiers = EMPTY_STAT_MODIFIERS}
		elseif skillName == EMPTY_STRING then
			return {ic = DEFAULT_ICONS[category], name = EMPTY_TEXT_LONG, StatModifiers = EMPTY_STAT_MODIFIERS}
		end

		local query = cargo.query("Skills", "CONCAT(Icon)=ic,_pageName=page,Name=name,Description=desc,WikiName=wikiname,StatModifiers,Cooldown=cd,Next=nextSkill,PromotionRarity=prarity,PromotionTier=ptier", {
			where = ("'%s' IN (Name,_pageName,WikiName) AND (%s) AND IFNULL(RefinePath,'-')='%s'"):format(escq(skillName), CATEGORY_QUERIES[category], refine or "-"),
			limit = 1,
		})[1]
		if not query then
			return {invalid = true, ic = MISSING_ICONS[category], name = skillName, StatModifiers = EMPTY_STAT_MODIFIERS}
		end

		if disableSkillDesc then
			query.desc = nil
		end
		if query.ic == '' then
			query.ic = DEFAULT_ICONS[category]
		end
		query.cd = tonumber(query.cd)
		query.prarity = tonumber(query.prarity)
		query.ptier = tonumber(query.ptier)
		query.StatModifiers = List.map_self(mw.text.split(query.StatModifiers, ','), function (x) return tonumber(x) end)
		for i = 1, 5 do
			query.StatModifiers[i] = query.StatModifiers[i] or 0
		end
		return query
	end, function (skillName, category, refine)
		return (skillName or '') .. ';' .. (category or '') .. ';' .. (refine or '')
	end)
end)()



local objectArgStruct = function (t)
	if t then
		local assocs = {}
		for k, v in Hash.sorted_pairs(t) do
			assocs[#assocs + 1] = ('%s=%s'):format(k, v)
		end
		return ('{%s}'):format(table.concat(assocs, ';'))
	end
end

local Template_Hover_nocargo = function (text, title)
	if Util.isNilOrEmpty(title) then
		return text
	end
	return tostring(mw.html.create('span'):attr('title', mw.text.decode(title)) -- mw.html automatically HTML-escapes attributes
		:css('border-bottom', '0'):css('text-decoration', 'underline dotted'):css('cursor', 'help')
		:wikitext(text))
end

local Template_SkillPage_nocargo = function (page, name, desc)
	if Util.isNilOrEmpty(name) then
		return Util.isNilOrEmpty(page) and '' or ('[[%s]]'):format(page)
	end
	if Util.isNilOrEmpty(page) then
		return name or ''
	end
	return ('[[%s|%s]]'):format(page, Util.isNilOrEmpty(desc) and name or Template_Hover_nocargo(name, stripWikitext(desc)))
end

local getMoveIcon = function (wikiname)
	local mov = MOVE_TYPES[wikiname]
	if mov then
		return ('[[File:%s|x26px|alt=%s|link=%s]]'):format(mov.IconFile, mov.Name, mov._pageName)
	elseif not Util.isNilOrEmpty(wikiname) then
		return "[[File:Icon_Move_" .. wikiname .. ".png|x26px|link=]]"
	else
		return ''
	end
end

local getWeaponIcon = function (wikiname)
	local wep = WEAPON_TYPES[wikiname]
	if wep then
		return ('[[File:%s|x26px|alt=%s|link=%s]]'):format(wep.IconFile, wep.Name, wep._pageName)
	elseif not Util.isNilOrEmpty(wikiname) then
		return "[[File:Icon_Class_" .. wikiname .. ".png|x26px|link=]]"
	else
		return ''
	end
end

local makeStatRow = function (title, text)
	local tr = mw.html.create("tr")
	tr:tag("th"):attr("scope", "row"):css("text-align", "left"):wikitext(title)
	tr:tag("td"):wikitext(text)
	return tr
end

local makeSkillRow = function (icon, text, pkind)
	local tr = mw.html.create('tr'):css('height', '25%')
	if pkind then
		tr:tag("td"):css("width", "15%"):node(superimposeDiv(("[[File:%s|x23px|alt=|link=]]"):format(icon),
			{('[[File:Passive Icon %s.png|14x14px|link=]]'):format(pkind), 12, 10}))
	else
		tr:tag("td"):css("width", "15%"):wikitext(("[[File:%s|x23px|alt=|link=]]"):format(icon))
	end
	tr:tag("td"):css("width", "85%"):wikitext(text)
	return tr
end

local statTextFunc = function (stat, modifier)
	if not stat or not tonumber(stat) then
		return UNKNOWN_TEXT
	else
		stat = math.min(99, math.max(0, tonumber(stat)))
		local finalStat = math.min(99, math.max(0, stat + modifier))
		if finalStat ~= stat then
			return Template_Hover_nocargo(finalStat, "Without skills: " .. stat)
		else
			return stat
		end
	end
end

local rarityFunc = function (text)
	if not text then
		return "[[File:RarityLineU.png|x25px|Rarity]]"
	else
		return "[[File:RarityLine" .. text .. ".png|x25px|alt=" .. text .. "★|Rarity]]"
	end
end

local lvFunc = function (text, displayed)
	if not text then
		return UNKNOWN_TEXT
	elseif (tonumber(text) or 0) > tonumber(displayed) then
		return Template_Hover_nocargo(text, "Displayed as " .. displayed .. "+ in‐game.")
	else
		return text
	end
end

local slotFunc = function (text)
	if not text then
		return Template_Hover_nocargo(UNKNOWN_TEXT, "It is unknown what this unit's slot order is.")
	elseif text == EMPTY_STRING then
		return ""
	else
		return Template_Hover_nocargo("#" .. text, "This unit is in slot " .. text .. ".")
	end
end

local cooldownFunc = function (cdcount, initcd)
	if cdcount then
		local text = Template_Hover_nocargo(cdcount, 'This unit has a Special cooldown count of ' .. cdcount .. '.')
		if initcd then
			text = Template_Hover_nocargo(initcd, 'This unit has an initial Special cooldown of ' .. initcd .. '.') .. ' / ' .. text
		end
		return text
	end
	return ""
end

local blessingFunc = function (text)
	if text and text ~= EMPTY_STRING then
		local escQBlessing = escq(text)
		local blessingQuery = cargo.query( "Items", "Name,ImageFile", { where="_pageName LIKE '".. escQBlessing .. "%' OR Name LIKE '".. escQBlessing .. "%'", limit=1 } )[1]
		if blessingQuery then
			return Template_Hover_nocargo("[[File:Map_Unit_Info_"..(blessingQuery["ImageFile"] or "Blank.png").."|border|x20px|alt=" .. (blessingQuery["Name"] or text) .. "|link=|]]",
				"This unit has the " .. mw.ustring.lower(blessingQuery["Name"] or text) .. " conferred.")
		end
	end
	return ""
end

local accessoryFunc = function (text)
	if text and text ~= EMPTY_STRING then
		local accessoryQuery = cargo.query( "Accessories", "_pageName,Name,Type", { where="'"..escq(text).."' IN (_pageName,Name)", limit=1 } )[1]
		if accessoryQuery then
			return "[[File:Accessory_Type_"..(accessoryQuery["Type"] or "unknown")..".png|border|x20px|alt=" .. accessoryQuery["Name"] .. "|link="..accessoryQuery["_pageName"].."]]"
		end
	end
	return ""
end

local skillFunc = function (query)
	local txt = Template_SkillPage_nocargo(query.page, query.name, query.desc)
	if query.invalid then
		txt = ("[[%s]]"):format(txt)
	end
	return query.ic, txt
end



local getUnitQuery = function (unitInfo)
	local unitQueries = memoizeUnits(unitInfo.unit)
	return unitQueries[#unitQueries <= 1 and 1 or List.find_if(unitQueries, function (query)
		if query.isEnemy then
			return (not unitInfo.properties.is_ally or unitInfo.properties.use_enemy_stats) and not unitInfo.properties.use_ally_stats
		else
			return (unitInfo.properties.is_ally or unitInfo.properties.use_ally_stats) and not unitInfo.properties.use_enemy_stats
		end
	end)]
end

local skillPromotion = function (unitInfo, cat, mptier)
	if Util.isNilOrEmpty(unitInfo[cat]) or unitInfo[cat] == EMPTY_STRING then
		return unitInfo[cat]
	end
	local skill = memoizeSkill(unitInfo[cat], cat)
	if skill.invalid then
		return unitInfo[cat]
	end

	if cat == 'weapon' then
		local unitQuery = getUnitQuery(unitInfo)
		local weapons = memoizeUnitWeapons(unitQuery.wikiname)
		if unitQuery.isEnemy then
			if weapons[unitInfo.rarity] then
				return weapons[unitInfo.rarity]
			end
		elseif mptier and skill.ptier and skill.ptier <= mptier then
			local oldRarity = List.find(weapons, skill.wikiname) -- prevent skill demotion?
			if oldRarity and oldRarity <= unitInfo.rarity and weapons[unitInfo.rarity] then
				return weapons[unitInfo.rarity]
			end
		end
	end

	if mptier then
		while skill.nextSkill ~= '' and skill.prarity and skill.prarity <= unitInfo.rarity and skill.ptier and skill.ptier <= mptier do
			local skill2 = memoizeSkill(skill.nextSkill, cat)
			if skill2.invalid then
				break
			end
			skill = skill2
		end
	end
	return skill.wikiname
end



local translateAIInfo = function (info)
	if info == 'activeall' then
		return {turn = 1}
	elseif info == 'passivelinked' then
		return {group = 1}
	elseif info == 'passivesingle' then
		return {}
	else
		return info
	end
end

local parseGlobalAIInfo = function (str)
	if Util.isNilOrEmpty(str) then
		return {turn = 1}
	end
	local info, err = parseArgs(str)
	if err then
		return nil, require 'Module:Error'.error(err)
	else
		return translateAIInfo(info)
	end
end

local makeAITable = function (infos, globalai)
	local tbl = mw.html.create('table'):addClass('wikitable'):css('text-align', 'center')

	local row = tbl:tag('tr')
	row:tag('th'):wikitext('Group')
	row:tag('th'):wikitext('Index')
	row:tag('th'):wikitext('Unit')
	row:tag('th'):wikitext('Start turn')
	row:tag('th'):wikitext('Notes')

	local getTurnTxt = function (unitInfo, inGroup)
		local hasTurn = type(unitInfo.ai.turn) == 'number'
		if inGroup then
			local hasDelay = type(unitInfo.ai.delay) == 'number'
			if hasTurn and hasDelay then
				return 'Turn ' .. unitInfo.ai.turn .. ' or ' .. unitInfo.ai.delay .. ' turn(s) after group is engaged'
			elseif hasTurn and not hasDelay then
				return 'Turn ' .. unitInfo.ai.turn .. ' or after group is engaged'
			elseif hasDelay then
				return unitInfo.ai.delay .. ' turn(s) after group is engaged'
			else
				return 'After group is engaged'
			end
		elseif hasTurn then
			return 'Turn ' .. unitInfo.ai.turn
		else
			return 'None'
		end
	end

	local fillUnit = function (unitInfo, inGroup)
		local unitQuery = getUnitQuery(unitInfo)
		row:tag('td'):wikitext(type(unitInfo.slot) == 'number' and ('#' .. unitInfo.slot) or UNKNOWN_TEXT)
		row:tag('td'):wikitext(unitInfo.random and 'Random' or
			unitQuery and ('[[%s|%s]]'):format(unitQuery.page, unitQuery.name) or unitInfo.unit)
		row:tag('td'):wikitext(getTurnTxt(unitInfo, inGroup))

		local notes = table.concat(List.compact {
			unitInfo.ai.break_walls and 'Attacks breakable terrain' or nil,
			unitInfo.ai.tether and 'Returns to starting position if unit has no actions to make' or nil,
		}, '<br />')
		row:tag('td'):wikitext(notes == '' and EMPTY_TEXT_LONG or notes)
	end

	local unitsByAIGroup = List.group_by(List.select(infos, function (v)
		return not v.properties.is_ally
	end), function (v)
		return v.ai and tonumber(v.ai.group) or math.huge
	end)
	for group, units in Hash.sorted_pairs(unitsByAIGroup) do
		table.sort(units, function (x, y) return x._index < y._index end)
		row = tbl:tag('tr')
		if group < math.huge then
			row:tag('td'):attr('rowspan', #units):wikitext(group)
			for i, unitInfo in ipairs(units) do
				if i > 1 then
					row = tbl:tag('tr')
				end
				fillUnit(unitInfo, true)
			end
		else
			for _, unitInfo in ipairs(units) do
				row = tbl:tag('tr')
				row:tag('td'):wikitext(EMPTY_TEXT_LONG)
				fillUnit(unitInfo, false)
			end
		end
	end

	return ('<p><b>AI settings</b></p>%s<p><i>See [[AI]] for a detailed description of the enemy movement settings.</i></p>'):format(tostring(tbl))
end

local parseUnitData = function (unitInfo, opts)
	local unitQuery = getUnitQuery(unitInfo)
	local unitImage = "[[File:Hero_No_Image_Face_FC.png|x79px|link=]]"
	local unitPageLink = ""
	local moveTypeIcon = ""
	local weaponTypeIcon = ""

	if unitInfo.random then
		unitPageLink = "<br/>Random"
		if not unitInfo.random.moves then
			for wikiname in Hash.sorted_pairs(MOVE_TYPES, function (v1, v2) return v1.Sort < v2.Sort end) do
				moveTypeIcon = moveTypeIcon .. getMoveIcon(wikiname)
			end
		else
			moveTypeIcon = getMoveIcon(unitInfo.random.moves)
		end
		local weaponList = unitInfo.random.weapons
		local excludeStaff = toboolean(unitInfo.random.staff) == false
		for wikiname, v in Hash.sorted_pairs(WEAPON_TYPES, function (v1, v2) return v1.Sort < v2.Sort end) do
			if (not weaponList or List.find(v.Classes, weaponList) or v.Type == weaponList or wikiname == weaponList) and not (v.Type == 'Staff' and excludeStaff) then
				weaponTypeIcon = weaponTypeIcon .. getWeaponIcon(wikiname)
			end
		end
	elseif unitQuery then
		local cc = opts.ccArgs
		if cc then
			cc.level = tonumber(cc.level)
			cc.rarity = tonumber(cc.rarity)
			cc.hpfactor = tonumber(cc.hpfactor)
			cc.ptier = tonumber(cc.ptier)

			if unitInfo.level and unitInfo.rarity then
				local newLv = math.max(unitInfo.level, cc.level or 1)
				local newRarity = math.max(unitInfo.rarity, cc.rarity or 1)
				local baseStats = List.map(STATS, function (stat) return tonumber(unitQuery['Lv1' .. stat .. '5']) or 0 end)
				local growthRates = List.map(STATS, function (stat) return tonumber(unitQuery[stat .. 'GR3']) or 0 end)

				if unitInfo.rarity < newRarity then
					local rarityBonuses = FEHStatUtil.getRarityBonuses(baseStats)
					List.map_self(unitInfo.stats, function (statVal, i)
						return statVal and statVal + rarityBonuses[newRarity][i] - rarityBonuses[unitInfo.rarity][i]
					end)
				end

				List.map_self(unitInfo.stats, function (statVal, i)
					return statVal and statVal + math.floor((
						FEHStatUtil.getMasterGrowthRate(newRarity, growthRates[i]) * (newLv - 1) -
						FEHStatUtil.getMasterGrowthRate(unitInfo.rarity, growthRates[i]) * (unitInfo.level - 1)) / 100)
				end)

				unitInfo.rarity = newRarity
				unitInfo.level = newLv
				unitInfo._lv = newLv
			end

			for _, cat in ipairs(SKILL_CATEGORIES) do
				if not (cat == 'weapon' and unitInfo.refine) then
					unitInfo[cat] = skillPromotion(unitInfo, cat, cc.ptier)
				end
			end

			if unitInfo.stats[1] and cc.hpfactor then
				unitInfo.stats[1] = math.max(1, math.floor(unitInfo.stats[1] * cc.hpfactor))
			end
		end

		local unitProperties = mw.text.split(unitQuery.Properties, '%s*,%s*')
		unitPageLink = "[[".. unitQuery.page .."|" .. unitQuery.name .. "]]"
		unitImage = ('<div %s">[[File:%s_%s.png|x79px|link=%s]]</div>'):format(
			unitInfo.isEnemy and 'style="transform:scaleX(-1);' or '',
			mf(unitQuery.page), unitQuery.isGeneric and 'Mini_Unit_Idle' or 'Face_FC', unitQuery.page)
		moveTypeIcon = getMoveIcon(unitQuery.MoveType)
		weaponTypeIcon = getWeaponIcon(unitQuery.WeaponType)
	elseif not Util.isNilOrEmpty(unitInfo.unit) then
		unitPageLink = "<br/>[[".. unitInfo.unit .. "]]"
	end

	local skillQueries = Hash.generate(SKILL_CATEGORIES, function (cat)
		return memoizeSkill(unitInfo[cat], cat, cat == 'weapon' and unitInfo.refine or nil)
	end)
	local hasInvalid = Hash.any(skillQueries, function (v) return v.invalid end)
	local statModifiers = List.map(List.transpose(List.map(Hash.values(skillQueries), function (t)
		return t.StatModifiers
	end)), function (stats) return List.sum(stats) end)

	local cooldownCount = nil
	if skillQueries.special.wikiname then
		if unitInfo.max_cooldown then
			cooldownCount = unitInfo.max_cooldown
		else
			cooldownCount = 0
			for _, v in pairs(skillQueries) do
				if v.wikiname and v.cd then
					cooldownCount = cooldownCount + v.cd
				end
			end
			cooldownCount = math.max(1, cooldownCount)
		end
	end

	-- obtain icons and texts
	local hpText = statTextFunc(unitInfo.stats[1], statModifiers[1])
	local atkText = statTextFunc(unitInfo.stats[2], statModifiers[2])
	local spdText = statTextFunc(unitInfo.stats[3], statModifiers[3])
	local defText = statTextFunc(unitInfo.stats[4], statModifiers[4])
	local resText = statTextFunc(unitInfo.stats[5], statModifiers[5])

	local rarityText = rarityFunc(unitInfo.rarity)
	local lvText = lvFunc(unitInfo._lv, unitInfo.displaylevel or 40)
	local slotText = slotFunc(unitInfo.slot)
	local cooldownText = cooldownFunc(cooldownCount, unitInfo.cooldown)
	local blessingText = blessingFunc(unitInfo.blessing)
	local accessoryText = accessoryFunc(unitInfo.accessory)

	local weaponIcon, weaponText = skillFunc(skillQueries["weapon"])
	local assistIcon, assistText = skillFunc(skillQueries["assist"])
	local specialIcon, specialText = skillFunc(skillQueries["special"])
	local passiveAIcon, passiveAText = skillFunc(skillQueries["a"])
	local passiveBIcon, passiveBText = skillFunc(skillQueries["b"])
	local passiveCIcon, passiveCText = skillFunc(skillQueries["c"])
	local passiveSIcon, passiveSText = skillFunc(skillQueries["seal"])

	if cooldownText ~= '' then
		specialText = specialText .. ' (' .. cooldownText .. ')'
	end

	-- generate sub-tables
	local statTable = mw.html.create("table"):css("width", "100%"):css("height", "100%"):css("text-align", "right")
	statTable:node(makeStatRow("LV.", lvText))
	statTable:node(makeStatRow("HP", hpText))
	statTable:node(makeStatRow("Atk", atkText))
	statTable:node(makeStatRow("Spd", spdText))
	statTable:node(makeStatRow("Def", defText))
	statTable:node(makeStatRow("Res", resText))

--[[
+--------+-------+--------+
| rarity | stats | random |
+--------+       |        |
|  slot  |       |        |
+--------+       |        |
|  unit  |       |        |
+--------+-------+--------+
]]
	if unitInfo.random then
		local randomTable = mw.html.create("table"):css("width", "100%"):css("height", "100%")
		randomTable:tag('tr'):tag('td'):attr('colspan', 2):wikitext('This unit is randomly generated.')
		randomTable:tag('tr'):tag('td'):css('width', '30%'):wikitext("'''Move types'''"):done()
			:tag('td'):css('width', '70%'):wikitext("'''Weapon types'''")
		randomTable:tag('tr'):tag('td'):wikitext(moveTypeIcon):done()
			:tag('td'):wikitext(weaponTypeIcon)

		local uTbl = mw.html.create("table"):css("text-align","center"):css("width","100%"):css("height", "100%"):css("table-layout", "fixed")

		local row = uTbl:tag("tr")
		row:tag("td"):css("width", "28%"):wikitext(rarityText)
		row:tag("td"):attr("rowspan", 3):css("width", "16%"):css("height", "100%"):css("padding-right", "2em"):node(statTable)
		row:tag("td"):attr("rowspan", 3):css("width", "56%"):node(randomTable)
		uTbl:tag("tr"):tag("td"):wikitext(slotText)
		uTbl:tag("tr"):tag("td"):wikitext(unitImage .. unitPageLink) -- hero icon and name

		return uTbl, hasInvalid
	end

	local skills1Table = mw.html.create("table"):css("width", "100%"):css("height", "100%"):css("table-layout", "fixed")
	skills1Table:node(makeSkillRow(weaponIcon, weaponText))
	skills1Table:node(makeSkillRow(assistIcon, assistText))
	skills1Table:node(makeSkillRow(specialIcon, specialText))
	skills1Table:tag("tr"):css("height", "25%"):tag("td"):attr("colspan", 2)
		:wikitext(mw.text.trim(blessingText .. " " .. accessoryText))

	local passivesTable = mw.html.create("table"):css("width", "100%"):css("height", "100%")
	passivesTable:node(makeSkillRow(passiveAIcon, passiveAText, 'A'))
	passivesTable:node(makeSkillRow(passiveBIcon, passiveBText, 'B'))
	passivesTable:node(makeSkillRow(passiveCIcon, passiveCText, 'C'))
	passivesTable:node(makeSkillRow(passiveSIcon, passiveSText, 'S'))

--[[
+-----------+-------+--------+----------+
|  rarity   | stats | skills | passives |
+-----------+       |        |          |
|   slot    |       |        |          |
+-----------+       |        |          |
|   unit    |       |        |          |
+-----+-----+       |        |          |
| mov | wep |       |        |          |
+-----+-----+-------+--------+----------+
]]
	-- generate html
	local uTbl = mw.html.create("table"):css("text-align","center"):css("width","100%"):css("height", "100%"):css("table-layout", "fixed")

	local row = uTbl:tag("tr")
	row:tag("td"):attr("colspan", 2):css("width", "28%"):wikitext(rarityText)
	row:tag("td"):attr("rowspan", 4):css("width", "16%"):css("height", "100%"):css("padding-right", "2em"):node(statTable)
	row:tag("td"):attr("rowspan", 4):css("width", "28%"):node(skills1Table)
	row:tag("td"):attr("rowspan", 4):css("width", "28%"):node(passivesTable)

	row = uTbl:tag("tr")
	row:tag("td"):attr("colspan", 2):wikitext(slotText)

	row = uTbl:tag("tr")
	row:tag("td"):attr("colspan", 2):wikitext(unitImage .. unitPageLink) -- hero icon and name

	row = uTbl:tag("tr")
	row:tag("td"):wikitext(weaponTypeIcon) -- weapon type icon
	row:tag("td"):wikitext(moveTypeIcon) -- move icon

	if toboolean(opts['no cargo']) or opts.ccArgs then
		return uTbl, hasInvalid
	end

	local propertiesClone = Hash.clone(unitInfo.properties)
	propertiesClone.use_ally_stats = nil -- unnecessary as wikiname is used to identify unit
	propertiesClone.use_enemy_stats = nil -- unnecessary as wikiname is used to identify unit

	return uTbl, hasInvalid, {
		tabname = opts.tabname,
		difficulty = opts.difficulty,
		name = unitQuery and unitQuery.page,
		unit = unitQuery and unitQuery.wikiname,
		pos = unitInfo.pos,
		rarity = unitInfo.rarity,
		slot = unitInfo.slot,
		cooldown = unitInfo.cooldown,
		blessing = unitInfo.blessing,
		level = unitInfo._lv,
		hp = unitInfo.stats[1],
		atk = unitInfo.stats[2],
		spd = unitInfo.stats[3],
		def = unitInfo.stats[4],
		res = unitInfo.stats[5],
		weapon = skillQueries.weapon.wikiname,
		assist = skillQueries.assist.wikiname,
		special = skillQueries.special.wikiname,
		a = skillQueries.a.wikiname,
		b = skillQueries.b.wikiname,
		c = skillQueries.c.wikiname,
		seal = skillQueries.seal.wikiname,
		accessory = unitInfo.accessory,
		ai = objectArgStruct(unitInfo.ai), -- unless there is a good reason to query their subfields individually, these will be passed as objectargs
		spawn = objectArgStruct(unitInfo.spawn),
		properties = table.concat(Hash.keys(propertiesClone), ','),
	}
end

local parseTabData = function (unitObj, opts)
	local infos, err = unitObj
	if type(infos) == 'string' then
		infos, err = parseArgs(unitObj == '' and '[]' or unitObj)
		if err then
			return require 'Module:Error'.error(err)
		end
	end

	-- sanitize input
	if opts.ccArgs and not next(opts.ccArgs) then
		opts.ccArgs = nil
	end
	for i, unitInfo in ipairs(infos) do
		unitInfo._index = i
		unitInfo._lv = unitInfo.level
		unitInfo.level = tonumber(unitInfo.level)
		unitInfo.rarity = tonumber(unitInfo.rarity)
		if not unitInfo.stats then
			unitInfo.stats = List.map(STATS, function (stat) return unitInfo[mw.ustring.lower(stat)] or false end)
		end
		List.map_self(unitInfo.stats, function (v) return tonumber(v) or false end)
		if not unitInfo.properties then
			unitInfo.properties = {}
		elseif type(unitInfo.properties) == 'string' then
			unitInfo.properties = List.to_set(mw.text.split(unitInfo.properties, ','))
		end
		unitInfo.ai = unitInfo.ai or opts.globalai or {turn = 1}
		if unitInfo.spawn and Util.isNilOrEmpty(opts.mapImage) then
			return require 'Module:Error'.error('A reinforcement unit has been specified but the mapImage parameter is missing.')
		end
	end
	local hasInvalid = false

	-- workaround for lua not having true hash tables
	local unitsBySpawn = Hash.from_ipairs(infos, function (u)
		local spawn = u.spawn or {count = -1}
		return setmetatable({
			u.properties.is_ally and 0 or 1,
			spawn.cond or '',
			tonumber(spawn.turn) or -1,
			spawn.target or '',
			tonumber(spawn.kills) or -1,
			tonumber(spawn.remain) or -1,
			tonumber(spawn.count) or 1,
		}, ORDERING_MIXIN), u
	end)
	local unitsBySpawnFlatten = {}
	for spawn, unitInfo in Hash.sorted_pairs(unitsBySpawn) do
		if #unitsBySpawnFlatten == 0 or spawn ~= unitsBySpawnFlatten[#unitsBySpawnFlatten][1] then
			table.insert(unitsBySpawnFlatten, {spawn, {}})
		end
		table.insert(unitsBySpawnFlatten[#unitsBySpawnFlatten][2], unitInfo)
	end
	local allyStartIdx = List.find_if(unitsBySpawnFlatten, function (v) return v[1][1] == 0 end)
	local enemyStartIdx = List.find_if(unitsBySpawnFlatten, function (v) return v[1][1] == 1 end)
	local enemyReinfStartIdx = List.find_if(unitsBySpawnFlatten, function (v) return v[1][1] == 1 and v[1][7] ~= -1 end)

	local ns = mw.title.getCurrentTitle().namespace

	local tables = List.map(unitsBySpawnFlatten, function (allSpawns, i)
		local spawn, infosPart = unpack(allSpawns)
		table.sort(infosPart, function (x, y) return x._index < y._index end)

		local entry = mw.html.create('div')

		if i == allyStartIdx then
			entry:tag('p'):tag('b'):wikitext('Ally units')
		elseif i == enemyStartIdx then
			entry:tag('p'):tag('b'):wikitext('Enemy units')
		elseif i == enemyReinfStartIdx then
			entry:tag('p'):tag('b'):wikitext('Enemy reinforcements')
		end

		if spawn[7] ~= -1 then -- *.spawn.count
			local reinfmap = entry:tag('table'):addClass('wikitable'):addClass('default'):addClass('mw-collapsed'):addClass('mw-collapsible')
			local txtnode = reinfmap:tag('tr'):tag('th')
			txtnode:wikitext('Reinforcement')
			if spawn[2] ~= '' then -- *.spawn.cond
				txtnode:wikitext(spawn[2])
			else
				if spawn[7] ~= 1 then -- *.spawn.count
					txtnode:wikitext((' (×%d)'):format(spawn[7])) -- *.spawn.count
				end
				txtnode:wikitext(': ')
				if spawn[3] ~= -1 then -- *.spawn.turn
					txtnode:wikitext('Turn ', spawn[3]) -- *.spawn.turn
					if spawn[4] ~= '' then -- *.spawn.target
						txtnode:wikitext(' or later, ')
					end
				end
				if spawn[4] ~= '' then -- *.spawn.target
					local target_count = spawn[6] ~= -1 and spawn[6] or spawn[5] -- *.spawn.remain or *.spawn.kills
					txtnode:wikitext(('%d %s unit%s %s'):format(target_count, spawn[4], target_count > 1 and 's' or '',
						spawn[6] ~= -1 and 'remaining' or 'killed'))
				end
			end

			local mapArgs = {}
			for _, unitInfo in ipairs(infosPart) do
				if unitInfo.pos then
					mapArgs[unitInfo.pos] = tostring(MapLayout.makeUnitIcon_noCargo(getUnitQuery(unitInfo), spawn[1] == 0 and 'Ally' or 'Enemy'))
				end
			end
			reinfmap:tag('tr'):tag('td'):node(superimposeDiv(opts.mapImage or '', {MapLayout._map(mapArgs), 0, 0}))
		end

		local tbl = entry:tag("table"):addClass("wikitable"):addClass("unitdata-unit-table")
			:css("text-align", "center"):css('border-spacing', '1px')
		tbl:tag("tr"):tag("th"):attr("scope", "colgroup"):wikitext(spawn[1] == 0 and 'Ally data' or 'Enemy data')

		for _, unitInfo in ipairs(infosPart) do
			local unitRow, invalid, cargoArgs = parseUnitData(unitInfo, opts)
			tbl:tag('tr'):css("height", "18em"):tag('td'):node(unitRow)
			hasInvalid = hasInvalid or invalid

			-- Store in Cargo
			if cargoArgs and ns == 0 then
				mw.getCurrentFrame():expandTemplate {title = "MapUnitDefinition", args = cargoArgs}
			end
		end

		return tostring(entry)
	end)

	if not toboolean(opts['no ai']) then
		table.insert(tables, 1, opts.globalaierror or makeAITable(infos, opts.globalai))
	end
	if opts.ccArgs then
		if Util.isNilOrEmpty(opts.mapImage) then
			return require 'Module:Error'.error('An infobox for the derived map is to be generated but the mapImage parameter is missing.')
		end
		local initMapArgs = {allyPos = opts.allyPos}
		for _, unitInfo in ipairs(infos) do
			if unitInfo.pos and (unitInfo.spawn and (unitInfo.spawn.count or 1) or -1) == -1 then
				initMapArgs[unitInfo.pos] = tostring(MapLayout.makeUnitIcon_noCargo(getUnitQuery(unitInfo), unitInfo.properties.is_ally and 'Ally' or 'Enemy'))
			end
		end
		table.insert(tables, 1, mw.getCurrentFrame():expandTemplate {title = 'Derived Map Infobox', args = {
			battle = opts.battle,
			baseMap = opts.mapPage,
			baseTab = opts.tabnameOrig,
			level = opts.ccArgs.level,
			rarity = opts.ccArgs.rarity,
			hpfactor = opts.ccArgs.hpfactor,
			mapImage = tostring(superimposeDiv(opts.mapImage, {MapLayout._map(initMapArgs), 0, 0})),
		}})
	end
	if ns == 0 then
		if List.any(infos, function (v) return not v.random and List.any(SKILL_CATEGORIES, function (cat) return not v[cat] end) end) then
			tables[#tables + 1] = '[[Category:Pages with incomplete unit data]]'
		end
		if hasInvalid then
			tables[#tables + 1] = '[[Category:Pages with script errors]]'
		end
	end
	return table.concat(tables, '\n')
end



local p = {}

p.makeTable = function (frame)
	local globalai, globalaierror = parseGlobalAIInfo(frame.args.globalai)
	local opts = {
		globalai = globalai,
		globalaierror = globalaierror,
		mapImage = frame.args.mapImage,
		tabname = frame.args.tabName,
		difficulty = frame.args.difficulty,
		battle = frame.args.battle,
		['no cargo'] = frame.args['no cargo'],
		['no ai'] = frame.args['no ai'],
		ccArgs = {
			hpfactor = tonumber(frame.args.hpfactor),
			level = tonumber(frame.args.level),
			rarity = tonumber(frame.args.rarity),
			ptier = tonumber(frame.args.ptier),
		},
	}
	return parseTabData(frame.args[1], opts)
end

local findDifficulty = function (tabName)
	return select(2, List.find_if(DIFFICULTY_ORDER, function (difficulty)
		return mw.ustring.find(tabName, difficulty, 1, true)
	end))
end

local NOEMPTY_UNIT_FIELDS = {'pos', 'rarity', 'slot', 'cooldown', 'blessing', 'level', 'hp', 'atk', 'spd', 'def', 'res', 'accessory', 'ai', 'spawn'}

p.main = function (frame)
	local allUnits = Hash.clone(frame.args)
	local derived = Hash.remove(allUnits, 'derived')
	if derived then
		derived = mw.loadData 'Module:UnitData/data'.derived_settings[derived]
	end
	local globalai, globalaierror = parseGlobalAIInfo(Hash.remove(allUnits, 'globalai'))
	local opts = {
		globalai = globalai,
		globalaierror = globalaierror,
		battle = Hash.remove(allUnits, 'battle'),
		mapImage = Hash.remove(allUnits, 'mapImage'),
		['no cargo'] = Hash.remove(allUnits, 'no cargo'),
		['no ai'] = Hash.remove(allUnits, 'no ai'),
	}

	local tabsStr = Hash.remove(allUnits, 'derivedTabs')
	if tabsStr then
		local tabMap, err = parseArgs(tabsStr)
		if err then
			return require 'Module:Error'.error(err)
		end
		for from, to in pairs(tabMap) do
			allUnits[to] = {from = from}
		end

		disableSkillDesc = true
		memoizeUnitsWithWikiName()
		opts.mapPage = Hash.remove(allUnits, 'derivedMap') or mw.title.getCurrentTitle().fullText
		opts.allyPos = Hash.remove(allUnits, 'allyPos')
		local baseUnits = cargo.query(
			'MapUnits',
			[[TabName,Unit=unit,Pos=pos,Rarity=rarity,Slot=slot,Cooldown=cooldown,Blessing=blessing,Level=level,
			  HP=hp,Atk=atk,Spd=spd,Def=def,Res=res,Weapon=weapon,Assist=assist,Special=special,PassiveA=a,PassiveB=b,PassiveC=c,Seal=seal,
			  Accessory=accessory,AI=ai,Spawn=spawn,Properties__full=properties]], {
			where = ("_pageName='%s' AND Unit IS NOT NULL"):format(escq(opts.mapPage)),
			orderBy = 'Slot,Unit',
			limit = 100,
		})
		for _, unitInfo in ipairs(baseUnits) do
			if tabMap[unitInfo.TabName] then
				for _, k in ipairs(NOEMPTY_UNIT_FIELDS) do
					if unitInfo[k] == '' then
						unitInfo[k] = nil
					end
				end
				for _, cat in ipairs(SKILL_CATEGORIES) do
					unitInfo[cat] = unitInfo[cat] == '' and '-' or unitInfo[cat]
				end
				if unitInfo.ai then
					unitInfo.ai = parseArgs(unitInfo.ai)
				end
				if unitInfo.spawn then
					unitInfo.spawn = parseArgs(unitInfo.spawn)
				end
				unitInfo.properties = List.to_set(mw.text.split(unitInfo.properties, '%s*,%s*'))
				for k, v in pairs(unitInfo) do
					if type(v) ~= 'table' then
						unitInfo[k] = tonumber(v) or v
					end
				end
				table.insert(allUnits[tabMap[unitInfo.TabName]], unitInfo)
			end
		end
	end

	local tabberArgs = {}
	for tabName, unitList in Hash.sorted_pairs(allUnits, function (_, _, k1, k2) return Util.difficultySort(k1, k2) end) do
		opts.tabnameOrig = unitList.from
		opts.tabname = tabName
		opts.difficulty = findDifficulty(tabName)
		opts.ccArgs = derived and derived[opts.difficulty]
		if opts.ccArgs then -- remove metatable
			opts.ccArgs = Hash.clone(opts.ccArgs)
		end
		tabberArgs[#tabberArgs + 1] = {tabName, '\n' .. tostring(parseTabData(unitList, opts))}
	end

	return Tab.tabber(tabberArgs):css( "overflow-x", "auto" )
end

-- for doc page
p.derivedSettingsList = function ()
	local DERIVED_SETTINGS = mw.loadData 'Module:UnitData/data'.derived_settings
	local deriveds = {}
	for k, v in Hash.sorted_pairs(DERIVED_SETTINGS) do
		deriveds[#deriveds + 1] = ("* <code>%s</code>: %s"):format(k,
			table.concat(List.select(DIFFICULTY_ORDER, function (dif) return v[dif] end), ', '))
	end
	return table.concat(deriveds, '\n')
end

return p