Перейти к содержимому страницы
Открыть меню
  • 528 статей
  • 3 тыс. файлов
  • 73 пользователей
  • 5,4 тыс. правки
Лабиринт - база знаний
Переключить меню настроек
Открыть персональное меню
Вы не представились системе
Ваш IP-адрес будет виден всем, если вы внесёте какие-либо изменения.

Модуль:Магия

Материал из Лабиринт - база знаний

Для документации этого модуля может быть создана страница Модуль:Магия/doc

-- Навигация и карточки раздела «Магия».
--
-- Ведётся в git: pages/Модуль/Магия.lua в репозитории playlabirint/wiki.
--
-- Дерево школ, направлений и навыков лежит на вики страницей данных
-- Модуль:Магия/дерево.json — её правят редакторы, когда добавляют навык или
-- направление. Раньше то же дерево было размазано по десятку шаблонов с
-- #switch, и добавление навыка означало правку в трёх местах.
--
-- Старые шаблоны («Магические школы», «Магические скиллы», «Меню магии» и
-- прочие) стали тонкими обёртками над этим модулем, поэтому статьи менять не
-- пришлось.

local character = require( 'Модуль:Персонаж' )
local icon = character.icon

local p = {}

local TREE_PAGE = 'Модуль:Магия/дерево.json'
-- Разделитель списков от #labmembers и #labparents расширения LabirintDesign.
local SEP = '‖'

-- Цвет школы: у каждой свой, как в макетах.
local SCHOOL_ACCENT = {
	['Школа Стихий'] = 'lab-acc-amber',
	['Школа Электро'] = 'lab-acc-cyan',
	['Школа Света'] = 'lab-acc-green',
	['Школа Тьмы'] = 'lab-acc-violet',
	['Школа Разума'] = 'lab-acc-pink',
	['Школа Измерений'] = 'lab-acc-teal',
}

-- Разделы верхнего меню.
local MENU = {
	{ page = 'Магия:Магическая система', label = 'Магическая система' },
	{ page = 'Магия:Магические классы', label = 'Классы' },
	{ page = 'Магия:Магические школы', label = 'Школы' },
}

-- Тип заклинания: подпись и значок.
local SPELL_TYPES = {
	['Направленная'] = { label = 'Направленная магия', ico = 'cast' },
	['Защитная'] = { label = 'Защитная магия', ico = 'shield' },
	['Нейтральная'] = { label = 'Нейтральная магия', ico = 'wand' },
	['Ментальная'] = { label = 'Ментальная магия', ico = 'eye' },
}

local function nonEmpty( s )
	if s == nil then return nil end
	s = mw.text.trim( tostring( s ) )
	return s ~= '' and s or nil
end

-- Сравнение названий: «тёмных» и «темных», разный регистр.
local function same( a, b )
	if not a or not b then return false end
	local function key( s )
		return mw.ustring.lower( ( mw.ustring.gsub( tostring( s ), 'ё', 'е' ) ) )
	end
	return key( a ) == key( b )
end

-- Длина списка из loadJsonData: данные приходят обёрткой только для чтения,
-- и оператор # на ней всегда даёт ноль.
local function count( list )
	local n = 0
	for _ in ipairs( list or {} ) do n = n + 1 end
	return n
end

local function tree()
	local ok, data = pcall( mw.loadJsonData, TREE_PAGE )
	if not ok or type( data ) ~= 'table' then return { ['школы'] = {}, ['классы'] = {} } end
	return {
		['школы'] = type( data['школы'] ) == 'table' and data['школы'] or {},
		['классы'] = type( data['классы'] ) == 'table' and data['классы'] or {},
	}
end

-- Где мы в дереве: школа, направление и навык текущей страницы.
local function locate( data, page )
	for _, school in ipairs( data['школы'] ) do
		if same( school['страница'], page ) or same( school['название'], page ) then
			return school
		end
		for _, dir in ipairs( school['направления'] or {} ) do
			if same( dir['страница'], page ) then
				return school, dir
			end
			for _, skill in ipairs( dir['навыки'] or {} ) do
				if same( skill['страница'], page ) then
					return school, dir, skill
				end
			end
		end
	end
end

local function link( page, label, class )
	local el = mw.html.create( 'span' ):addClass( class )
	el:wikitext( '[[' .. page .. '|' .. label .. ']]' )
	return el
end

local function tile( parent, class, item, active )
	local el = parent:tag( 'span' ):addClass( class )
	if active then el:addClass( 'is-on' ) end
	local iconFile = nonEmpty( item['иконка'] )
	if iconFile then
		el:tag( 'span' ):addClass( 'lab-magic__ico' )
			:wikitext( '[[Файл:' .. iconFile .. '|64px|link=|alt=]]' )
	end
	el:tag( 'span' ):addClass( 'lab-magic__name' )
		:wikitext( '[[' .. item['страница'] .. '|' .. item['название'] .. ']]' )
	return el
end

-- ---------- Меню и путь ----------

function p.menu( frame )
	local data = tree()
	local page = mw.title.getCurrentTitle().prefixedText
	local school, dir, skill = locate( data, page )
	local current = nonEmpty( frame:getParent().args[1] )

	local bar = mw.html.create( 'div' ):addClass( 'lab-magic__bar lab-blk' )
	local nav = bar:tag( 'span' ):addClass( 'lab-magic__menu' )
	for _, item in ipairs( MENU ) do
		local active = same( item.page, page ) or same( item.label, current )
			or ( school and item.label == 'Школы' )
			or ( current == 'Магические классы' and item.label == 'Классы' )
		local el = nav:tag( 'span' ):addClass( 'lab-magic__menuItem' )
		if active then el:addClass( 'is-on' ) end
		el:wikitext( '[[' .. item.page .. '|' .. item.label .. ']]' )
	end

	-- Путь: школа → направление → текущая страница.
	if school then
		local crumbs = bar:tag( 'span' ):addClass( 'lab-magic__crumbs' )
		local function crumb( item, last )
			local el = crumbs:tag( 'span' ):addClass( 'lab-magic__crumb' )
			local iconFile = nonEmpty( item['иконка'] )
			if iconFile then
				el:tag( 'span' ):addClass( 'lab-magic__crumbIco' )
					:wikitext( '[[Файл:' .. iconFile .. '|24px|link=|alt=]]' )
			end
			if last then
				el:addClass( 'is-current' ):wikitext( item['название'] )
			else
				el:wikitext( '[[' .. item['страница'] .. '|' .. item['название'] .. ']]' )
			end
		end
		crumb( school, not dir )
		if dir then crumb( dir, not skill ) end
		if skill then crumb( skill, true ) end
	end

	-- Поиск по способностям: со страницы раздела можно попасть сразу в нужную,
	-- не проходя школу и направление. Поле рисует скрипт, а список приходит
	-- готовым — без скрипта он просто не показывается.
	bar:tag( 'span' ):addClass( 'lab-magic__find' )
	local list = bar:tag( 'span' ):addClass( 'lab-magic__findList' ):attr( 'hidden', 'hidden' )
	for _, item in ipairs( data['школы'] ) do
		for _, dir in ipairs( item['направления'] or {} ) do
			for _, skill in ipairs( dir['навыки'] or {} ) do
				local el = list:tag( 'span' ):addClass( 'lab-magic__findItem' )
					:attr( 'data-where', item['название'] .. ' · ' .. dir['название'] )
				el:wikitext( '[[' .. skill['страница'] .. '|' .. skill['название'] .. ']]' )
			end
		end
	end

	local root = mw.html.create( 'div' ):addClass( 'lab-magic' )
	if school and SCHOOL_ACCENT[school['название']] then
		root:addClass( SCHOOL_ACCENT[school['название']] )
	end
	root:node( bar )
	return tostring( root )
end

-- ---------- Школы, направления, навыки ----------

-- На странице навыка плитки школ и направлений не нужны: там колонка школы
-- сбоку и путь в шапке, как в макете. Шаблоны зовут эти функции всегда —
-- решает модуль.
local function onSkillPage()
	local data = tree()
	local _, _, skill = locate( data, mw.title.getCurrentTitle().prefixedText )
	return skill ~= nil
end

-- Карточки школ с направлениями: на обзорных страницах («Магическая система»,
-- «Магические школы») читателю нужен не переключатель, а что внутри школы.
local function schoolCards( data )
	local grid = mw.html.create( 'div' ):addClass( 'lab-magic__scards' )
	for _, school in ipairs( data['школы'] ) do
		local card = grid:tag( 'div' ):addClass( 'lab-magic__scard ' .. ( SCHOOL_ACCENT[school['название']] or '' ) )
		local iconFile = nonEmpty( school['иконка'] )
		if iconFile then
			card:tag( 'span' ):addClass( 'lab-magic__scardIco' )
				:wikitext( '[[Файл:' .. iconFile .. '|72px|link=|alt=]]' )
		end
		local body = card:tag( 'div' ):addClass( 'lab-magic__scardBody' )
		body:tag( 'div' ):addClass( 'lab-magic__scardName lab-display' )
			:wikitext( '[[' .. school['страница'] .. '|' .. school['название'] .. ']]' )
		local dirs = body:tag( 'div' ):addClass( 'lab-magic__scardDirs' )
		for _, dir in ipairs( school['направления'] or {} ) do
			dirs:tag( 'span' ):addClass( 'lab-magic__dirChip' )
				:wikitext( '[[' .. dir['страница'] .. '|' .. dir['название'] .. ']]' )
		end
	end
	return tostring( grid )
end

function p.schools( frame )
	local data = tree()
	local page = mw.title.getCurrentTitle().prefixedText
	local current = nonEmpty( frame:getParent().args[1] )
	-- Внутри школы плитки работают переключателем, снаружи — витриной.
	local school = locate( data, page )
	if not school and not current then
		return schoolCards( data )
	end
	local grid = mw.html.create( 'div' ):addClass( 'lab-magic__stabs' )
	for _, item in ipairs( data['школы'] ) do
		local el = grid:tag( 'span' )
			:addClass( 'lab-magic__stab ' .. ( SCHOOL_ACCENT[item['название']] or '' ) )
			:attr( 'data-school', item['название'] )
		if same( item['страница'], page ) or same( item['название'], current ) then
			el:addClass( 'is-on' )
		end
		local iconFile = nonEmpty( item['иконка'] )
		if iconFile then
			el:tag( 'span' ):addClass( 'lab-magic__stabIco' )
				:wikitext( '[[Файл:' .. iconFile .. '|44px|link=|alt=]]' )
		end
		el:wikitext( '[[' .. item['страница'] .. '|' .. item['название'] .. ']]' )
	end
	return tostring( grid )
end

-- Колонка школы слева: направления с числом навыков, у текущего направления
-- раскрыты сами навыки. Стоит на страницах школы, направления и навыка —
-- рисует её p.directions, чтобы колонка была одна на странице.
local function treeColumn( data, page )
	local school, current = locate( data, page )
	if not school then return nil end
	local box = mw.html.create( 'div' )
		:addClass( 'lab-magic__tree lab-blk ' .. ( SCHOOL_ACCENT[school['название']] or '' ) )
	local head = box:tag( 'div' ):addClass( 'lab-blk__head' )
	head:tag( 'span' ):addClass( 'lab-bar' )
	head:tag( 'span' ):addClass( 'lab-blk__title' ):wikitext( school['название'] )
	head:tag( 'span' ):addClass( 'lab-hline' )
	head:tag( 'span' ):addClass( 'lab-mark' )
	local body = box:tag( 'div' ):addClass( 'lab-magic__treeBody' )
	for _, dir in ipairs( school['направления'] or {} ) do
		local active = current and same( dir['название'], current['название'] )
		local row = body:tag( 'span' ):addClass( 'lab-magic__treeDir' )
		if active then row:addClass( 'is-on' ) end
		local iconFile = nonEmpty( dir['иконка'] )
		if iconFile then
			row:tag( 'span' ):addClass( 'lab-magic__treeIco' )
				:wikitext( '[[Файл:' .. iconFile .. '|28px|link=|alt=]]' )
		end
		row:wikitext( '[[' .. dir['страница'] .. '|' .. dir['название'] .. ']]' )
		row:tag( 'span' ):addClass( 'lab-magic__count' ):wikitext( count( dir['навыки'] ) )
		if active then
			for _, skill in ipairs( dir['навыки'] or {} ) do
				local el = body:tag( 'span' ):addClass( 'lab-magic__treeSkill' )
				if same( skill['страница'], page ) then el:addClass( 'is-on' ) end
				el:wikitext( '[[' .. skill['страница'] .. '|' .. skill['название'] .. ']]' )
			end
		end
	end
	return box
end

-- Шапка страницы школы или направления: значок, надзаголовок и название.
local function sectionHead( item, eyebrow )
	local head = mw.html.create( 'div' ):addClass( 'lab-magic__head' )
	local iconFile = nonEmpty( item['иконка'] )
	if iconFile then
		local art = head:tag( 'span' ):addClass( 'lab-magic__headIco' )
		art:tag( 'span' ):addClass( 'lab-magic__headGlow' )
		art:tag( 'span' ):wikitext( '[[Файл:' .. iconFile .. '|104px|link=|alt=]]' )
	end
	local body = head:tag( 'div' ):addClass( 'lab-magic__headBody' )
	body:tag( 'div' ):addClass( 'lab-magic__headEyebrow' ):wikitext( eyebrow )
	body:tag( 'div' ):addClass( 'lab-magic__headName lab-display' ):wikitext( item['название'] )
	return head
end

-- Столбец направления: значок, название, число навыков и сами навыки
-- строками. Не карточка: колонка школы слева уже показывает направления, и
-- второй раз обводить их рамкой незачем — здесь нужен список способностей.
local function directionColumn( parent, dir )
	local column = parent:tag( 'div' ):addClass( 'lab-magic__dircol' )
	local head = column:tag( 'div' ):addClass( 'lab-magic__dircolHead' )
	local iconFile = nonEmpty( dir['иконка'] )
	if iconFile then
		head:tag( 'span' ):addClass( 'lab-magic__dircolIco' )
			:wikitext( '[[Файл:' .. iconFile .. '|24px|link=|alt=]]' )
	end
	head:tag( 'span' ):addClass( 'lab-magic__dircolName' )
		:wikitext( '[[' .. dir['страница'] .. '|' .. dir['название'] .. ']]' )
	head:tag( 'span' ):addClass( 'lab-magic__count' ):wikitext( count( dir['навыки'] ) )
	local list = column:tag( 'div' ):addClass( 'lab-magic__dircolList' )
	for _, skill in ipairs( dir['навыки'] or {} ) do
		list:tag( 'span' ):addClass( 'lab-magic__dircolSkill' )
			:wikitext( '[[' .. skill['страница'] .. '|' .. skill['название'] .. ']]' )
	end
end

function p.directions( frame )
	local args = frame:getParent().args
	local data = tree()
	local page = mw.title.getCurrentTitle().prefixedText
	local schoolName = nonEmpty( args[1] )
	local school, current, skill = locate( data, page )
	school = school or ( function ()
		for _, item in ipairs( data['школы'] ) do
			if same( item['название'], schoolName ) then return item end
		end
	end )()
	if not school then return '' end

	local out = {}
	local column = treeColumn( data, page )
	if column then out[#out + 1] = tostring( column ) end

	-- Шапка страницы: у школы — её значок и название, у направления — свои.
	-- На странице навыка шапку рисует Шаблон:Тип магии вместе с типом.
	if not skill then
		if current then
			out[#out + 1] = tostring( sectionHead( current, school['название'] ) )
		else
			out[#out + 1] = tostring( sectionHead( school, 'Магические школы' ) )
		end
	end
	return table.concat( out )
end

-- Первая фраза статьи: короткое пояснение к способности в списке направления.
-- Текст берётся со страницы навыка — отдельно его никто не пишет.
local function summary( page )
	local title = page and mw.title.new( page )
	local text = title and title:getContent()
	if not text then return nil end
	-- Вызовы шаблонов, заголовки и разметку выбрасываем: нужна проза.
	text = text:gsub( '%b{}', '' ):gsub( '<[^>]->', '' )
	for line in ( text .. '\n' ):gmatch( '(.-)\r?\n' ) do
		line = mw.text.trim( line )
		if line ~= '' and not line:match( '^[=*:;#|_]' ) then
			line = line:gsub( "'''?", '' ):gsub( '%[%[[^%]|]-|([^%]]-)%]%]', '%1' ):gsub( '%[%[([^%]]-)%]%]', '%1' )
			-- До конца первого предложения, но не длиннее строки списка.
			local sentence = mw.ustring.match( line, '^(.-[%.!%?])%s' ) or line
			if mw.ustring.len( sentence ) > 180 then
				sentence = mw.ustring.sub( sentence, 1, 177 ) .. '…'
			end
			return sentence
		end
	end
	return nil
end

-- Тип заклинания со страницы навыка: {{Тип магии|Направленная}}.
local function skillType( page )
	local title = page and mw.title.new( page )
	local text = title and title:getContent()
	if not text then return nil end
	-- Класс символов сюда не годится: кириллица многобайтовая, и [Тт] в
	-- шаблоне Lua разбирается по байтам.
	local value = text:match( '{{%s*Тип[_ ]магии%s*|%s*([^|}]-)%s*[|}]' )
		or text:match( '{{%s*тип[_ ]магии%s*|%s*([^|}]-)%s*[|}]' )
	return value and nonEmpty( value ) or nil
end

-- Способности направления: название, тип и первая фраза статьи. Ставится под
-- текстом направления — Шаблон:Способности направления.
function p.directionSkills( frame )
	local data = tree()
	local page = mw.title.getCurrentTitle().prefixedText
	local _, direction = locate( data, page )
	if not direction then
		local name = nonEmpty( frame:getParent().args[1] )
		for _, school in ipairs( data['школы'] ) do
			for _, dir in ipairs( school['направления'] or {} ) do
				if same( dir['название'], name ) then direction = dir end
			end
		end
	end
	if not direction then
		return '<strong class="error">Шаблон:Способности направления — направление не найдено</strong>'
	end

	local grid = mw.html.create( 'div' ):addClass( 'lab-magic__skilllist' )
	for _, skill in ipairs( direction['навыки'] or {} ) do
		local row = grid:tag( 'div' ):addClass( 'lab-magic__skillrow' )
		local head = row:tag( 'div' ):addClass( 'lab-magic__skillhead' )
		head:tag( 'span' ):addClass( 'lab-magic__skillname lab-display' )
			:wikitext( '[[' .. skill['страница'] .. '|' .. skill['название'] .. ']]' )
		local kind = skillType( skill['страница'] )
		local spell = kind and SPELL_TYPES[kind]
		if spell then
			local chip = head:tag( 'span' ):addClass( 'lab-magic__skillkind' )
			chip:node( icon( spell.ico ) )
			chip:wikitext( spell.label )
		end
		local text = summary( skill['страница'] )
		if text then
			row:tag( 'div' ):addClass( 'lab-magic__skilltext' ):wikitext( text )
		end
	end
	return tostring( grid )
end

-- Витрина направлений школы: ставится в конец страницы школы, под текстом,
-- поэтому отдельным шаблоном — Шаблон:Направления школы.
function p.schoolDirections( frame )
	local data = tree()
	local page = mw.title.getCurrentTitle().prefixedText
	local school = locate( data, page )
	if not school then
		local name = nonEmpty( frame:getParent().args[1] )
		for _, item in ipairs( data['школы'] ) do
			if same( item['название'], name ) then school = item end
		end
	end
	if not school then
		return '<strong class="error">Шаблон:Направления школы — школа не найдена</strong>'
	end
	local grid = mw.html.create( 'div' )
		:addClass( 'lab-magic__dircols ' .. ( SCHOOL_ACCENT[school['название']] or '' ) )
	for _, dir in ipairs( school['направления'] or {} ) do
		directionColumn( grid, dir )
	end
	return tostring( grid )
end

-- Список навыков направления: на странице направления и на самих навыках.
-- Кнопки навыков в начале страницы направления больше не нужны: под текстом
-- стоит список способностей с пояснениями, а рядом — колонка школы.
function p.skills()
	return ''
end

function p.tree( frame )
	local column = treeColumn( tree(), mw.title.getCurrentTitle().prefixedText )
	return column and tostring( column ) or ''
end

-- Все способности одним списком: свёрнутый показывает школы, дальше
-- раскрывается до направлений и самих способностей. Так можно посмотреть
-- дерево целиком, не уходя со страницы.
--
-- Разметку отдаёт сервер целиком, сворачивает её скрипт (resources/magic.js):
-- без скрипта список просто открыт, и поисковик видит все названия.
function p.outline( frame )
	local data = tree()
	local root = mw.html.create( 'div' ):addClass( 'lab-outline lab-blk' )
	local head = root:tag( 'div' ):addClass( 'lab-blk__head' )
	head:tag( 'span' ):addClass( 'lab-bar' )
	head:tag( 'span' ):addClass( 'lab-blk__title' )
		:wikitext( nonEmpty( frame:getParent().args['Заголовок'] ) or 'Школы и способности' )
	head:tag( 'span' ):addClass( 'lab-hline' )
	head:tag( 'span' ):addClass( 'lab-mark' )
	-- Список прокручивается внутри блока, заголовок остаётся на месте.
	local list = root:tag( 'div' ):addClass( 'lab-outline__list' )
	for _, school in ipairs( data['школы'] ) do
		local item = list:tag( 'div' )
			:addClass( 'lab-outline__school ' .. ( SCHOOL_ACCENT[school['название']] or '' ) )

		local head = item:tag( 'div' ):addClass( 'lab-outline__row' )
		head:node( icon( 'chev' ):addClass( 'lab-outline__chev' ) )
		local iconFile = nonEmpty( school['иконка'] )
		if iconFile then
			head:tag( 'span' ):addClass( 'lab-outline__ico' )
				:wikitext( '[[Файл:' .. iconFile .. '|28px|link=|alt=]]' )
		end
		head:tag( 'span' ):addClass( 'lab-outline__name' ):wikitext( school['название'] )

		-- Сколько внутри: по числу видно, куда идти.
		local dirs, skills = 0, 0
		for _, dir in ipairs( school['направления'] or {} ) do
			dirs = dirs + 1
			skills = skills + count( dir['навыки'] )
		end
		head:tag( 'span' ):addClass( 'lab-outline__count' ):wikitext(
			dirs .. ' ' .. character.plural( dirs, 'направление', 'направления', 'направлений' )
			.. ' · ' .. skills .. ' ' .. character.plural( skills, 'способность', 'способности', 'способностей' )
		)
		-- Страница школы — отдельным значком: вся остальная строка раскрывает
		-- список, и курсор по ней не прыгает со ссылки на обычный.
		head:tag( 'span' ):addClass( 'lab-outline__go' )
			:wikitext( '[[' .. school['страница'] .. '|→]]' )

		local body = item:tag( 'div' ):addClass( 'lab-outline__body' )
		for _, dir in ipairs( school['направления'] or {} ) do
			local sub = body:tag( 'div' ):addClass( 'lab-outline__dir' )
			local row = sub:tag( 'div' ):addClass( 'lab-outline__row lab-outline__row--dir' )
			row:node( icon( 'chev' ):addClass( 'lab-outline__chev' ) )
			local dirIcon = nonEmpty( dir['иконка'] )
			if dirIcon then
				row:tag( 'span' ):addClass( 'lab-outline__ico' )
					:wikitext( '[[Файл:' .. dirIcon .. '|24px|link=|alt=]]' )
			end
			row:tag( 'span' ):addClass( 'lab-outline__name' ):wikitext( dir['название'] )
			row:tag( 'span' ):addClass( 'lab-outline__count' ):wikitext( count( dir['навыки'] ) )
			row:tag( 'span' ):addClass( 'lab-outline__go' )
				:wikitext( '[[' .. dir['страница'] .. '|→]]' )

			local list = sub:tag( 'div' ):addClass( 'lab-outline__body lab-outline__skills' )
			for _, skill in ipairs( dir['навыки'] or {} ) do
				list:tag( 'span' ):addClass( 'lab-outline__skill' )
					:wikitext( '[[' .. skill['страница'] .. '|' .. skill['название'] .. ']]' )
			end
		end
	end
	return tostring( root )
end

-- ---------- Классы ----------

-- Порядок статусов: в колонке класса сначала те, кто в игре.
local STATUS_ORDER = { ['В игре'] = 1, ['Недоступен'] = 2, ['Вне игры'] = 3 }
local STATUS_PAGE = 'Модуль:Персонажи/статусы.json'
local NAMES_SHOWN = 16

-- Ключ оформления класса: по нему берутся фон шапки и цвет (Модуль:Персонаж).
local function classKey( name )
	local cls = name and character.CLASSES[name]
	return cls and cls.key or nil
end

-- Вкладки классов в шапке: по макету они лежат на фотографии, а не отдельной
-- полосой плиток над ней.
local function classTabs( parent, data, page )
	local tabs = parent:tag( 'div' ):addClass( 'lab-magic__tabs' )
	for _, cls in ipairs( data['классы'] ) do
		local el = tabs:tag( 'span' ):addClass( 'lab-magic__tab' )
		local key = classKey( cls['название'] )
		-- Свой цвет вкладке, но не через lab-cls-*: этот класс перекрашивает
		-- всю страницу, а вкладок на ней пять.
		if key then el:addClass( 'lab-magic__tab--' .. key ) end
		if same( cls['страница'], page ) then el:addClass( 'is-on' ) end
		if key then
			el:tag( 'span' ):addClass( 'lab-emblem lab-emblem--' .. key )
		end
		el:wikitext( '[[' .. cls['страница'] .. '|' .. cls['название'] .. ']]' )
	end
end

-- Сколько персонажей в категории класса.
local function classCount( frame, name )
	local category = character.CLASS_CATEGORY[name]
	if not category then return nil end
	local out = frame:callParserFunction( '#labmembers', { category, 'ns=0', 'limit=500' } )
	local n = 0
	for title in mw.text.gsplit( out, SEP, true ) do
		if mw.text.trim( title ) ~= '' then n = n + 1 end
	end
	return n
end

-- Сколько кого в классе: «40 магов».
local CLASS_FORMS = {
	['Маг'] = { 'маг', 'мага', 'магов' },
	['Вампир'] = { 'вампир', 'вампира', 'вампиров' },
	['Оборотень'] = { 'оборотень', 'оборотня', 'оборотней' },
	['Крылатый'] = { 'крылатый', 'крылатых', 'крылатых' },
	['Призрак'] = { 'призрак', 'призрака', 'призраков' },
	['Человек'] = { 'человек', 'человека', 'людей' },
}

-- Карточки классов: иллюстрация класса, эмблема, название и сколько их в игре.
function p.classes( frame )
	local data = tree()
	local page = mw.title.getCurrentTitle().prefixedText
	local grid = mw.html.create( 'div' ):addClass( 'lab-magic__ccards' )
	for _, cls in ipairs( data['классы'] ) do
		local name = cls['название']
		local key = classKey( name )
		local card = grid:tag( 'span' ):addClass( 'lab-magic__ccard' )
		if key then
			card:addClass( 'lab-magic__ccard--' .. key )
		end
		if same( cls['страница'], page ) then card:addClass( 'is-on' ) end
		-- Ссылкой оборачивается вся карточка: внутри неё только текст, и
		-- нажиматься должна плашка целиком, а не одно название.
		local inner = mw.html.create( 'span' ):addClass( 'lab-magic__ccardBody' )
		if key then
			inner:tag( 'span' ):addClass( 'lab-emblem lab-emblem--' .. key )
		end
		inner:tag( 'span' ):addClass( 'lab-magic__ccardName lab-display' ):wikitext( name )
		local count = classCount( frame, name )
		local forms = CLASS_FORMS[name]
		if count and forms then
			inner:tag( 'span' ):addClass( 'lab-magic__ccardNum' )
				:wikitext( count .. ' ' .. character.plural( count, forms[1], forms[2], forms[3] ) )
		end
		card:wikitext( '[[' .. cls['страница'] .. '|' .. tostring( inner ) .. ']]' )
	end
	return tostring( grid )
end

-- Строки «* Свобода в выборе направлений» из параметра «Особенности».
local function features( text )
	local items = {}
	for line in ( ( text or '' ) .. '\n' ):gmatch( '(.-)\r?\n' ) do
		local item = line:match( '^%s*%*+%s*(.+)$' )
		if item then items[#items + 1] = mw.text.trim( item ) end
	end
	return items
end

-- Статусы персонажей: та же страница данных, что у списка персонажей. Её раз
-- в день обновляет скрипт игры, и записи в ней бывают любыми.
local function statuses()
	local ok, data = pcall( mw.loadJsonData, STATUS_PAGE )
	if not ok or type( data ) ~= 'table' then return {} end
	local people = data['персонажи'] or data
	if type( people ) ~= 'table' then return {} end
	local clean = {}
	for title, person in pairs( people ) do
		if type( title ) == 'string' and type( person ) == 'table' then
			clean[character.nameKey( title )] = person
		end
	end
	return clean
end

-- Колонка «Маги в игре»: имена из категории класса, сначала те, кто в игре.
local function players( parent, frame, category )
	local out = frame:callParserFunction( '#labmembers', { category, 'ns=0', 'limit=500' } )
	local status = statuses()
	local list = {}
	for title in mw.text.gsplit( out, SEP, true ) do
		title = mw.text.trim( title )
		if title ~= '' then
			local record = status[character.nameKey( title )]
			local value = record and nonEmpty( tostring( record['статус'] or '' ) ) or nil
			list[#list + 1] = {
				title = title,
				order = value and STATUS_ORDER[value] or 9,
				key = character.nameKey( title ),
			}
		end
	end
	if #list == 0 then return end
	table.sort( list, function ( a, b )
		if a.order ~= b.order then return a.order < b.order end
		return a.key < b.key
	end )

	local box = parent:tag( 'div' ):addClass( 'lab-blk lab-magic__players' )
	local head = box:tag( 'div' ):addClass( 'lab-blk__head' )
	head:tag( 'span' ):addClass( 'lab-bar' )
	head:tag( 'span' ):addClass( 'lab-blk__title' ):wikitext( category .. ' в игре' )
	head:tag( 'span' ):addClass( 'lab-magic__playersCount' ):wikitext( #list )
	head:tag( 'span' ):addClass( 'lab-hline' )
	head:tag( 'span' ):addClass( 'lab-mark' )
	local names = box:tag( 'div' ):addClass( 'lab-magic__names' )
	for i, item in ipairs( list ) do
		if i > NAMES_SHOWN then break end
		names:tag( 'span' ):wikitext( '[[' .. item.title .. ']]' )
	end
	-- «Все маги»: название категории как есть, без склонения — оно бывает
	-- любым, а счётчик и так стоит в заголовке блока.
	box:tag( 'div' ):addClass( 'lab-magic__namesFoot' )
		:wikitext( '[[:Категория:' .. category .. '|Все ' .. mw.ustring.lower( category ) .. ']]' )
end

-- Страница класса: шапка с фотографией и вкладками классов, под ней колонка с
-- особенностями и персонажами. Текст статьи обтекает колонку.
function p.class( frame )
	local args = frame:getParent().args
	local data = tree()
	local page = mw.title.getCurrentTitle()
	local name = nonEmpty( args['НазваниеКласса'] ) or page.text
	local key = classKey( name )

	local hero = mw.html.create( 'div' ):addClass( 'lab-magic__hero' )
	if key then
		hero:addClass( 'lab-magic__hero--' .. key ):addClass( 'lab-cls-' .. key )
	end
	classTabs( hero, data, page.prefixedText )

	local body = hero:tag( 'div' ):addClass( 'lab-magic__heroBody' )
	-- Эмблема класса — та же картинка, что в игровых профилях и на карточке
	-- персонажа (blocks/character.css), а не значок раздела вики.
	if key then
		local emblem = body:tag( 'span' ):addClass( 'lab-magic__emblem' )
		emblem:tag( 'span' ):addClass( 'lab-magic__emblemGlow' )
		emblem:tag( 'span' ):addClass( 'lab-magic__heroIco lab-emblem lab-emblem--' .. key )
	end
	body:tag( 'div' ):addClass( 'lab-magic__heroName lab-display' ):wikitext( name )
	local resource = nonEmpty( args['ОсновнойРесурс'] )
	if resource then
		body:tag( 'div' ):addClass( 'lab-magic__heroSub' )
			:wikitext( 'Основной ресурс · ' .. mw.ustring.lower( resource ) )
	end

	-- Основного ресурса в плашках нет: он стоит строкой под названием.
	local stats = {
		{ 'Восстановление', args['ВосстановлениеРесурса'] },
		{ 'Внешние отличия', args['ВнешниеОтличия'] },
		{ 'Свобода выбора пути', args['СвободаВыбораПути'] },
		{ 'Плата за способности', args['ПлатаЗаСпособности'] },
	}
	local grid = body:tag( 'div' ):addClass( 'lab-magic__stats' )
	for _, stat in ipairs( stats ) do
		local value = nonEmpty( stat[2] )
		if value then
			local box = grid:tag( 'div' ):addClass( 'lab-magic__stat' )
			-- Плашка рассчитана на пару слов, но в статьях о призраке и
			-- вампире там целые предложения: длинное значение показываем
			-- мельче, чтобы шапка не разъезжалась.
			if mw.ustring.len( value ) > 60 then
				box:addClass( 'lab-magic__stat--long' )
			end
			box:tag( 'div' ):addClass( 'lab-magic__statValue' ):wikitext( value )
			box:tag( 'div' ):addClass( 'lab-magic__statLabel' ):wikitext( stat[1] )
		end
	end

	-- Колонка справа: особенности и персонажи класса. Текст статьи её
	-- обтекает — как карточка на странице персонажа.
	local aside = mw.html.create( 'div' ):addClass( 'lab-magic__aside' )
	local items = features( args['Особенности'] )
	if #items > 0 then
		local box = aside:tag( 'div' ):addClass( 'lab-blk' )
		local head = box:tag( 'div' ):addClass( 'lab-blk__head' )
		head:tag( 'span' ):addClass( 'lab-bar' )
		head:tag( 'span' ):addClass( 'lab-blk__title' ):wikitext( 'Особенности' )
		head:tag( 'span' ):addClass( 'lab-hline' )
		head:tag( 'span' ):addClass( 'lab-mark' )
		local list = box:tag( 'div' ):addClass( 'lab-magic__feats' )
		for _, item in ipairs( items ) do
			local row = list:tag( 'span' ):addClass( 'lab-magic__feat' )
			row:tag( 'i' )
			row:tag( 'span' ):wikitext( item )
		end
	end
	local category = nonEmpty( args['КатегорияПерсонажей'] )
	if category then
		players( aside, frame, category )
	end

	return tostring( hero ) .. tostring( aside )
end

-- ---------- Направления класса ----------

-- Все направления дерева по названию: у вампира, оборотня и крылатого в
-- статье перечислены заблокированные и усиленные, и по названию нужно найти
-- страницу и значок.
local function directionsByName()
	local index = {}
	for _, school in ipairs( tree()['школы'] ) do
		for _, dir in ipairs( school['направления'] or {} ) do
			local name = nonEmpty( dir['название'] )
			if name then
				index[mw.ustring.lower( ( mw.ustring.gsub( name, 'ё', 'е' ) ) )] = dir
			end
		end
	end
	return index
end

local function affinityGroup( parent, index, label, text, kind )
	local items = {}
	for line in ( ( text or '' ) .. '\n' ):gmatch( '(.-)\r?\n' ) do
		local item = line:match( '^%s*%*+%s*(.+)$' )
		if item then items[#items + 1] = mw.text.trim( item ) end
	end
	if #items == 0 then return end

	local group = parent:tag( 'div' ):addClass( 'lab-magic__aff lab-magic__aff--' .. kind )
	group:tag( 'div' ):addClass( 'lab-magic__affLabel' ):wikitext( label )
	local row = group:tag( 'div' ):addClass( 'lab-magic__affRow' )
	for _, name in ipairs( items ) do
		local dir = index[mw.ustring.lower( ( mw.ustring.gsub( name, 'ё', 'е' ) ) )]
		local chip = row:tag( 'span' ):addClass( 'lab-magic__affItem' )
		local iconFile = dir and nonEmpty( dir['иконка'] )
		if iconFile then
			chip:tag( 'span' ):addClass( 'lab-magic__affIco' )
				:wikitext( '[[Файл:' .. iconFile .. '|24px|link=|alt=]]' )
		end
		-- Неизвестное направление остаётся текстом: дерево правят редакторы,
		-- и название в статье может разойтись с ним.
		chip:wikitext( dir and ( '[[' .. dir['страница'] .. '|' .. name .. ']]' ) or name )
	end
end

-- Что классу закрыто, а что даётся легче: Шаблон:Направления класса.
function p.affinity( frame )
	local args = frame:getParent().args
	local index = directionsByName()
	local root = mw.html.create( 'div' ):addClass( 'lab-magic__affinity' )
	affinityGroup( root, index, nonEmpty( args['ЗаголовокЗаблокированных'] ) or 'Заблокированные направления',
		args['Заблокированные'], 'off' )
	affinityGroup( root, index, nonEmpty( args['ЗаголовокУсиленных'] ) or 'Усиленные направления',
		args['Усиленные'], 'up' )
	return tostring( root )
end

-- ---------- Вопросы и ответы ----------

-- Панель со всеми вопросами класса. Внутри — группы («; Общие принципы»)
-- и вызовы Шаблон:ВопросОтвет.
function p.questions( frame )
	local args = frame:getParent().args
	local box = mw.html.create( 'div' ):addClass( 'lab-qa lab-blk' )
	local head = box:tag( 'div' ):addClass( 'lab-blk__head' )
	head:tag( 'span' ):addClass( 'lab-bar' )
	head:tag( 'span' ):addClass( 'lab-blk__title' )
		:wikitext( nonEmpty( args['Заголовок'] ) or 'Вопросы и ответы' )
	head:tag( 'span' ):addClass( 'lab-hline' )
	head:tag( 'span' ):addClass( 'lab-mark' )
	-- Содержимое отдельной строкой: внутри списки определений и шаблоны.
	return tostring( box ):gsub( '</div>$', '' )
		.. '<div class="lab-qa__body">\n' .. ( args[1] or '' ) .. '\n</div></div>'
end

-- Один вопрос: заголовок раскрывает ответ. Раскрывает скрипт
-- (resources/magic.js); без скрипта ответ просто виден целиком.
function p.question( frame )
	local args = frame:getParent().args
	local title = nonEmpty( args['ЗаголовокВопроса'] ) or 'Вопрос'
	local item = mw.html.create( 'div' ):addClass( 'lab-qa__item' )
	local head = item:tag( 'span' ):addClass( 'lab-qa__q' )
	head:tag( 'span' ):addClass( 'lab-qa__qText' ):wikitext( title )
	head:node( icon( 'plus' ):addClass( 'lab-qa__sign' ) )
	local answer = item:tag( 'div' ):addClass( 'lab-qa__a' )
	local full = nonEmpty( args['ПолныйВопрос'] )
	if full then
		answer:tag( 'div' ):addClass( 'lab-qa__full' ):wikitext( full )
	end
	answer:tag( 'div' ):addClass( 'lab-qa__answer' ):wikitext( args['Ответ'] or '' )
	return tostring( item )
end

-- ---------- Магическая система ----------

-- Классы во множественном числе: в колонке «Источники силы» и на карточках
-- подпись «Маги», а не «Маг».
local CLASS_PLURAL = {
	['Маг'] = 'Маги', ['Вампир'] = 'Вампиры', ['Оборотень'] = 'Оборотни',
	['Крылатый'] = 'Крылатые', ['Призрак'] = 'Призраки', ['Человек'] = 'Люди',
}

-- Основной ресурс класса берётся со страницы класса: там он стоит параметром
-- {{Магический класс|ОсновнойРесурс=…}}, и второй раз писать его не нужно.
local function classResource( page )
	local title = page and mw.title.new( page )
	local text = title and title:getContent()
	if not text then return nil end
	local args = character.templateArgs( text, 'Магический класс' )
	return nonEmpty( args['ОсновнойРесурс'] )
end

-- Шапка страницы «Магическая система»: картинка, название, вступление и
-- колонка «Источники силы» — по одному классу на строку.
function p.system( frame )
	local args = frame:getParent().args
	local data = tree()
	local page = mw.title.getCurrentTitle()

	local hero = mw.html.create( 'div' ):addClass( 'lab-magic__system' )
	local image = nonEmpty( args['Фон'] )
	if image then
		hero:tag( 'span' ):addClass( 'lab-magic__systemPhoto' )
			:wikitext( '[[Файл:' .. image .. '|1600px|link=|alt=]]' )
	else
		-- Без своей картинки шапка берёт иллюстрацию раздела из оформления
		-- (public/resources/assets/sections/magic).
		hero:addClass( 'lab-magic__system--art' )
	end

	local body = hero:tag( 'div' ):addClass( 'lab-magic__systemBody' )
	body:tag( 'div' ):addClass( 'lab-magic__systemEyebrow' )
		:wikitext( nonEmpty( args['Надзаголовок'] ) or 'Иртмис' )
	body:tag( 'div' ):addClass( 'lab-magic__systemTitle lab-display' )
		:wikitext( nonEmpty( args['Заголовок'] ) or page.text )
	local lead = nonEmpty( args['Вступление'] )
	if lead then
		body:tag( 'div' ):addClass( 'lab-magic__systemLead' ):wikitext( lead )
	end

	-- Источники силы: класс, эмблема и его ресурс.
	local box = hero:tag( 'div' ):addClass( 'lab-blk lab-magic__sources' )
	local head = box:tag( 'div' ):addClass( 'lab-blk__head' )
	head:tag( 'span' ):addClass( 'lab-bar' )
	head:tag( 'span' ):addClass( 'lab-blk__title' ):wikitext( 'Источники силы' )
	head:tag( 'span' ):addClass( 'lab-hline' )
	head:tag( 'span' ):addClass( 'lab-mark' )
	local list = box:tag( 'div' ):addClass( 'lab-magic__sourcesBody' )
	for _, cls in ipairs( data['классы'] ) do
		local name = cls['название']
		local key = classKey( name )
		local row = list:tag( 'span' ):addClass( 'lab-magic__source' )
		if key then
			row:addClass( 'lab-magic__tab--' .. key )
			row:tag( 'span' ):addClass( 'lab-emblem lab-emblem--' .. key )
		end
		local text = row:tag( 'span' ):addClass( 'lab-magic__sourceText' )
		text:tag( 'b' ):wikitext( '[[' .. cls['страница'] .. '|' .. ( CLASS_PLURAL[name] or name ) .. ']]' )
		local resource = classResource( cls['страница'] )
		if resource then
			text:tag( 'span' ):wikitext( resource )
		end
	end
	return tostring( hero )
end

-- Типы способностей: направленная, ментальная, защитная, нейтральная. Текст
-- пишется списком определений, значок подбирается по названию.
function p.types( frame )
	local args = frame:getParent().args
	local items = {}
	for line in ( ( args[1] or '' ) .. '\n' ):gmatch( '(.-)\r?\n' ) do
		local title = line:match( '^%s*;%s*(.+)$' )
		local desc = line:match( '^%s*:%s*(.+)$' )
		if title then
			local short, inline = title:match( '^(.-)%s+:%s*(.+)$' )
			items[#items + 1] = { title = mw.text.trim( short or title ), desc = inline and mw.text.trim( inline ) }
		elseif desc and #items > 0 then
			local last = items[#items]
			last.desc = last.desc and ( last.desc .. ' ' .. desc ) or mw.text.trim( desc )
		end
	end
	if #items == 0 then
		return '<strong class="error">Шаблон:Типы способностей — нет ни одного типа</strong>'
	end
	local grid = mw.html.create( 'div' ):addClass( 'lab-magic__kinds' )
	for _, item in ipairs( items ) do
		-- «Направленная магия» → значок из SPELL_TYPES, как на странице навыка.
		local kind = SPELL_TYPES[( item.title:gsub( '%s+магия$', '' ) )]
		local card = grid:tag( 'div' ):addClass( 'lab-magic__kind' )
		card:tag( 'span' ):addClass( 'lab-magic__kindIco' ):node( icon( kind and kind.ico or 'book' ) )
		card:tag( 'div' ):addClass( 'lab-magic__kindTitle lab-display' ):wikitext( item.title )
		if item.desc then
			card:tag( 'div' ):addClass( 'lab-magic__kindText' ):wikitext( item.desc )
		end
	end
	return tostring( grid )
end

-- Терминология: карточки в две колонки и поиск по ним (magic.js).
function p.terms( frame )
	local args = frame:getParent().args
	local items = {}
	for line in ( ( args[1] or '' ) .. '\n' ):gmatch( '(.-)\r?\n' ) do
		local title = line:match( '^%s*;%s*(.+)$' )
		local desc = line:match( '^%s*:%s*(.+)$' )
		if title then
			local short, inline = title:match( '^(.-)%s+:%s*(.+)$' )
			items[#items + 1] = { title = mw.text.trim( short or title ), desc = inline and mw.text.trim( inline ) }
		elseif desc and #items > 0 then
			local last = items[#items]
			last.desc = last.desc and ( last.desc .. ' ' .. desc ) or mw.text.trim( desc )
		end
	end
	if #items == 0 then
		return '<strong class="error">Шаблон:Термины — нет ни одного термина</strong>'
	end
	local root = mw.html.create( 'div' ):addClass( 'lab-terms' ):attr( 'data-total', #items )
	local head = root:tag( 'div' ):addClass( 'lab-terms__head' )
	head:tag( 'span' ):addClass( 'lab-terms__count' ):wikitext( #items )
	-- Поле поиска рисует скрипт: форму в вики-разметке парсер не пропускает.
	head:tag( 'span' ):addClass( 'lab-terms__search' )
	local list = root:tag( 'div' ):addClass( 'lab-terms__list' )
	for _, item in ipairs( items ) do
		local card = list:tag( 'div' ):addClass( 'lab-term' )
			:attr( 'data-term', mw.ustring.lower( item.title ) )
		card:tag( 'div' ):addClass( 'lab-term__name lab-display' ):wikitext( item.title )
		if item.desc then
			card:tag( 'div' ):addClass( 'lab-term__text' ):wikitext( item.desc )
		end
	end
	-- Строка «ничего не нашлось» прячется стилями: атрибут hidden вики из
	-- разметки вырезает, и без скрипта строка висела бы на странице.
	root:tag( 'div' ):addClass( 'lab-terms__empty' ):wikitext( 'Ничего не нашлось' )
	return tostring( root )
end

-- ---------- Заклинание ----------

-- Шапка страницы навыка: значок направления, путь школы, название и тип
-- заклинания. Зовёт её Шаблон:Тип магии — он стоит в начале каждой статьи о
-- способности, менять статьи ради шапки не пришлось.
function p.spellType( frame )
	local args = frame:getParent().args
	local value = nonEmpty( args[1] )
	local data = tree()
	local page = mw.title.getCurrentTitle()
	local school, direction, skill = locate( data, page.prefixedText )

	local chips = mw.html.create( 'span' ):addClass( 'lab-magic__types' )
	local spell = value and SPELL_TYPES[value]
	if spell then
		local chip = chips:tag( 'span' ):addClass( 'lab-magic__type' )
		chip:node( icon( spell.ico ) )
		chip:wikitext( spell.label )
	elseif value then
		chips:tag( 'span' ):addClass( 'lab-magic__type' ):wikitext( value )
	end
	if nonEmpty( args[2] ) == 'да' then
		local chip = chips:tag( 'span' ):addClass( 'lab-magic__type lab-magic__type--beast' )
		chip:node( icon( 'person' ) )
		chip:wikitext( 'Доступно в облике зверя' )
	end

	-- Вне дерева (черновик, страница вне раздела) — только плашки типа.
	if not skill then
		return tostring( chips )
	end

	local head = mw.html.create( 'div' ):addClass( 'lab-magic__head' )
	-- Идентификатор способности в сервисе магии: по нему выгрузка сходится с
	-- его базой, даже если страницу переименуют.
	local id = nonEmpty( args['ид'] )
	if id then head:attr( 'data-skill-id', id ) end
	local iconFile = direction and nonEmpty( direction['иконка'] )
	if iconFile then
		local art = head:tag( 'span' ):addClass( 'lab-magic__headIco' )
		art:tag( 'span' ):addClass( 'lab-magic__headGlow' )
		art:tag( 'span' ):wikitext( '[[Файл:' .. iconFile .. '|104px|link=|alt=]]' )
	end
	local body = head:tag( 'div' ):addClass( 'lab-magic__headBody' )
	body:tag( 'div' ):addClass( 'lab-magic__headEyebrow' )
		:wikitext( school['название'] .. ' · ' .. direction['название'] )
	body:tag( 'div' ):addClass( 'lab-magic__headName lab-display' ):wikitext( skill['название'] )
	body:node( chips )
	return tostring( head )
end

-- Уровень способности: шкала, описание и плашки воздействия.
--
-- «Воздействие» и «Разрушения» — отдельные параметры: так их читает и выгрузка
-- для сервиса магии (docs/pages.md). В старых статьях они написаны строками
-- «*'''Воздействие:''' …» внутри описания — их разбирает тот же код, пока
-- миграция не перенесёт их в параметры.
function p.level( frame )
	local args = frame:getParent().args
	local level = nonEmpty( args['Уровень'] ) or '?'
	local text = args['Описание'] or ''

	local prose, effects = {}, {}
	for line in ( text .. '\n' ):gmatch( '(.-)\r?\n' ) do
		local label, rest = line:match( "^%*%s*'''(.-):?'''%s*(.*)$" )
		if label then
			effects[#effects + 1] = { label = mw.text.trim( label ), text = mw.text.trim( rest ) }
		else
			prose[#prose + 1] = line
		end
	end
	-- Параметр «Эффекты» — строки определения: подписи у способностей разные
	-- («Воздействие», «Разрушения», «Эффект по площади»), список закрытым не
	-- сделаешь. Их же читает выгрузка для сервиса магии.
	for line in ( ( args['Эффекты'] or '' ) .. '\n' ):gmatch( '(.-)\r?\n' ) do
		local label = line:match( '^%s*;%s*(.+)$' )
		local value = line:match( '^%s*:%s*(.+)$' )
		if label then
			local short, inline = label:match( '^(.-)%s+:%s*(.+)$' )
			effects[#effects + 1] = { label = mw.text.trim( short or label ), text = inline and mw.text.trim( inline ) or '' }
		elseif value and #effects > 0 then
			local last = effects[#effects]
			last.text = last.text ~= '' and ( last.text .. ' ' .. mw.text.trim( value ) ) or mw.text.trim( value )
		end
	end

	local box = mw.html.create( 'div' ):addClass( 'lab-magic__level' ):attr( 'data-level', level )
	-- Стоимость и доступность приходят из сервиса магии страницей данных; пока
	-- её нет, значения берутся из параметров (docs/pages.md).
	local cost = nonEmpty( args['Стоимость'] )
	if cost then box:attr( 'data-cost', cost ) end
	local head = box:tag( 'div' ):addClass( 'lab-magic__levelHead' )
	local pips = head:tag( 'span' ):addClass( 'lab-magic__pips' )
	for i = 1, 5 do
		local pip = pips:tag( 'span' )
		if tonumber( level ) and i <= tonumber( level ) then pip:addClass( 'is-on' ) end
	end
	head:tag( 'span' ):addClass( 'lab-magic__levelName' ):wikitext( 'Уровень ' .. level )

	box:tag( 'div' ):addClass( 'lab-magic__levelBody' ):wikitext( '\n' .. table.concat( prose, '\n' ) .. '\n' )
	if #effects > 0 then
		local grid = box:tag( 'div' ):addClass( 'lab-magic__effects' )
		for _, effect in ipairs( effects ) do
			local el = grid:tag( 'div' ):addClass( 'lab-magic__effect' )
			el:tag( 'div' ):addClass( 'lab-magic__effectLabel' ):wikitext( effect.label )
			el:tag( 'div' ):addClass( 'lab-magic__effectText' ):wikitext( effect.text )
		end
	end
	return tostring( box )
end

p['меню'] = p.menu
p['школы'] = p.schools
p['направления'] = p.directions
p['навыки'] = p.skills
p['дерево'] = p.tree
p['направления школы'] = p.schoolDirections
p['способности направления'] = p.directionSkills
p['классы'] = p.classes
p['класс'] = p.class
p['система'] = p.system
p['все способности'] = p.outline
p['типы способностей'] = p.types
p['термины'] = p.terms
p['направления класса'] = p.affinity
p['вопросы'] = p.questions
p['вопрос'] = p.question
p['тип'] = p.spellType
p['уровень'] = p.level

return p