Перейти к содержанию

Модуль:Турнирная сетка

Материал из Наро-Фоминский Футбол
Версия от 18:17, 17 сентября 2026; Andrew (обсуждение | вклад) (Создан модуль)
(разн.) ← Предыдущая версия | Текущая версия (разн.) | Следующая версия → (разн.)

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

-- Module:Bracket
-- Вывод турнирной сетки на выбывание (single elimination) с матчем за 3-е место.
-- Поддерживает "баи" (проход без игры) и 1 или 2 матча на стадию, с датами.

local p = {}

-------------------------------------------------------------
-- Утилиты
-------------------------------------------------------------

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

local function toNum(s)
	if s == nil then return nil end
	return tonumber(s)
end

local function getArg(args, ...)
	for _, key in ipairs({ ... }) do
		local v = args[key]
		if v ~= nil and mw.text.trim(tostring(v)) ~= '' then
			return v
		end
	end
	return nil
end

local function yesno(s, default)
	if s == nil then return default end
	s = mw.text.trim(tostring(s)):lower()
	if s == '' then return default end
	if s == 'да' or s == 'yes' or s == 'true' or s == '1' then return true end
	if s == 'нет' or s == 'no' or s == 'false' or s == '0' then return false end
	return default
end

-------------------------------------------------------------
-- Названия раундов
-------------------------------------------------------------

local function buildAutoRoundNames(n)
	local names = {}
	for i = 1, n - 1 do
		local denom = 2 ^ (n - i)
		if denom == 2 then
			names[i] = '1/2 финала'
		else
			names[i] = '1/' .. tostring(denom) .. ' финала'
		end
	end
	names[n] = 'Финал'
	return names
end

local function parseRoundNames(args)
	local raw = getArg(args, 'раунды', 'rounds')
	if raw then
		local names = {}
		for part in mw.text.gsplit(raw, ',') do
			local t = trim(part)
			if t then table.insert(names, t) end
		end
		if #names > 0 then return names end
	end
	local cnt = toNum(getArg(args, 'раундов', 'rounds_count'))
	if cnt and cnt >= 1 then
		return buildAutoRoundNames(math.floor(cnt))
	end
	return nil
end

-------------------------------------------------------------
-- Чтение данных одной пары/матча
-------------------------------------------------------------

local function readMatch(args, prefix, defaultLegs)
	local m = {}
	m.team1 = trim(args[prefix .. 'к1'])
	m.team2 = trim(args[prefix .. 'к2'])
	m.score1 = trim(args[prefix .. 'сч1'])
	m.score2 = trim(args[prefix .. 'сч2'])
	m.score1_2 = trim(args[prefix .. 'сч1_2'])
	m.score2_2 = trim(args[prefix .. 'сч2_2'])
	m.date1 = trim(args[prefix .. 'дата'])
	m.date2 = trim(args[prefix .. 'дата_2'])
	m.legs = toNum(args[prefix .. 'игр']) or defaultLegs
	m.winnerOverride = trim(args[prefix .. 'победитель'])
	m.note = trim(args[prefix .. 'примечание'])
	return m
end

-------------------------------------------------------------
-- Определение победителя по ГОТОВЫМ (разрешённым) именам команд
-------------------------------------------------------------

local function simpleWinner(m)
	if m.winnerOverride == '1' or m.winnerOverride == '2' then
		return tonumber(m.winnerOverride)
	end
	if m.team1 and not m.team2 then return 1 end
	if m.team2 and not m.team1 then return 2 end
	if not (m.team1 and m.team2) then return nil end

	if m.legs and m.legs >= 2 then
		local a1, b1 = toNum(m.score1), toNum(m.score1_2)
		local a2, b2 = toNum(m.score2), toNum(m.score2_2)
		if a1 and b1 and a2 and b2 then
			local agg1, agg2 = a1 + b1, a2 + b2
			if agg1 > agg2 then return 1
			elseif agg2 > agg1 then return 2
			else return nil end
		end
		return nil
	else
		local s1, s2 = toNum(m.score1), toNum(m.score2)
		if s1 and s2 then
			if s1 > s2 then return 1
			elseif s2 > s1 then return 2
			else return nil end
		end
		return nil
	end
end

-------------------------------------------------------------
-- Взаимно-рекурсивные функции: какая команда стоит в слоте
-- и кто побеждает в слоте (с учётом "проросших" из предыдущего
-- раунда имён, а не только явно указанных).
-------------------------------------------------------------

local resolveTeam
local matchWinner

resolveTeam = function(matches, r, s, i)
	if r == 1 then
		return matches[1][s]['team' .. i]
	end

	local prevSlot = (i == 1) and (2 * s - 1) or (2 * s)
	local prevName1 = resolveTeam(matches, r - 1, prevSlot, 1)
	local prevName2 = resolveTeam(matches, r - 1, prevSlot, 2)

	if not (prevName1 or prevName2) then
		-- В предыдущем раунде в этой позиции матча вообще не было —
		-- значит это "бай": команда указывается явно на текущем раунде.
		return matches[r][s]['team' .. i]
	end

	local w = matchWinner(matches, r - 1, prevSlot)
	if w == 1 then
		return prevName1
	elseif w == 2 then
		return prevName2
	else
		-- Матч ещё не сыгран/не завершён — но, возможно, тут явно
		-- заранее указано имя (например, известная заявка) — как fallback.
		return matches[r][s]['team' .. i]
	end
end

matchWinner = function(matches, r, s)
	local raw = matches[r][s]
	local eff = {
		team1 = resolveTeam(matches, r, s, 1),
		team2 = resolveTeam(matches, r, s, 2),
		score1 = raw.score1,
		score2 = raw.score2,
		score1_2 = raw.score1_2,
		score2_2 = raw.score2_2,
		legs = raw.legs,
		winnerOverride = raw.winnerOverride,
	}
	return simpleWinner(eff)
end

local function resolveLoser(matches, n, i)
	-- Раунд n-1 всегда состоит ровно из 2 слотов (полуфинал) —
	-- отсюда автоматически берутся участники матча за 3-е место.
	local w = matchWinner(matches, n - 1, i)
	if not w then return nil end
	local loserIdx = (w == 1) and 2 or 1
	return resolveTeam(matches, n - 1, i, loserIdx)
end

local function displayName(matches, r, s, i)
	local name = resolveTeam(matches, r, s, i)
	if name then return name end
	if r == 1 then
		return '' -- пустой слот первого раунда - там просто нет игры
	end
	return '<span class="bracket-tbd">—</span>'
end

-------------------------------------------------------------
-- Форматирование счёта/даты
-------------------------------------------------------------

local function formatDate(d)
	if not d then return '' end
	return ' <small class="bracket-date">(' .. d .. ')</small>'
end

local function formatScore(m, teamIndex)
	local mainKey = 'score' .. teamIndex
	local legKey = 'score' .. teamIndex .. '_2'
	local parts = {}

	if m.legs and m.legs >= 2 then
		if m[mainKey] or m.date1 then
			table.insert(parts, (m[mainKey] or '?') .. formatDate(m.date1))
		end
		if m[legKey] or m.date2 then
			table.insert(parts, (m[legKey] or '?') .. formatDate(m.date2))
		end
		local a1, b1 = toNum(m.score1), toNum(m.score1_2)
		local a2, b2 = toNum(m.score2), toNum(m.score2_2)
		if a1 and b1 and a2 and b2 then
			local agg = (teamIndex == 1) and (a1 + b1) or (a2 + b2)
			table.insert(parts, '<b>' .. agg .. '</b>')
		end
	else
		if m[mainKey] then
			table.insert(parts, m[mainKey] .. formatDate(m.date1))
		elseif m.date1 then
			table.insert(parts, formatDate(m.date1))
		end
	end

	if m.note then
		table.insert(parts, '<small>' .. m.note .. '</small>')
	end

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

-------------------------------------------------------------
-- Геометрия таблицы: сколько строк (rowspan) занимает
-- ячейка команды раунда r, и с какой строки начинается.
-- Классическая схема "таблица с объединением ячеек",
-- как в спортивных сетках Википедии.
-------------------------------------------------------------

local function blockInfo(row, r)
	local blockSize = 2 ^ r
	local half = 2 ^ (r - 1)
	local idx0 = row - 1
	local slot = math.floor(idx0 / blockSize) + 1
	local posInBlock = idx0 % blockSize
	local teamIndex, offset
	if posInBlock < half then
		teamIndex, offset = 1, posInBlock
	else
		teamIndex, offset = 2, posInBlock - half
	end
	local isStart = (offset == 0)
	return slot, teamIndex, isStart, half
end

-------------------------------------------------------------
-- Основная функция
-------------------------------------------------------------

function p._main(args)
	local roundNames = parseRoundNames(args)
	if not roundNames then
		return '<strong class="error">Module:Bracket — не заданы раунды (параметр «раунды» или «раундов»).</strong>'
	end
	local n = #roundNames

	local defaultLegs = toNum(getArg(args, 'игр_на_стадии', 'legs')) or 1
	local title = trim(getArg(args, 'название', 'title'))
	local showChampion = yesno(getArg(args, 'чемпион', 'champion'), true)

	-- Строим структуру пар для всех раундов и слотов
	local matches = {}
	for r = 1, n do
		matches[r] = {}
		local slots = 2 ^ (n - r)
		for s = 1, slots do
			matches[r][s] = readMatch(args, 'р' .. r .. '_' .. s .. '_', defaultLegs)
		end
	end

	-- Матч за 3-е место
	local tpTeam1Given = trim(args['трм_к1'])
	local tpTeam2Given = trim(args['трм_к2'])
	local showThird = yesno(
		getArg(args, 'третье_место', 'third_place'),
		(tpTeam1Given ~= nil or tpTeam2Given ~= nil)
	)
	local thirdName = trim(getArg(args, 'третье_место_название', 'third_place_name'))
		or 'Матч за 3-е место'
	local tp
	if showThird then
		tp = readMatch(args, 'трм_', defaultLegs)
		if n >= 2 then
			tp.team1 = tp.team1 or resolveLoser(matches, n, 1)
			tp.team2 = tp.team2 or resolveLoser(matches, n, 2)
		end
	end

	-- Рендер
	local root = mw.html.create('div'):addClass('bracket-wrapper')
	if title then
		root:tag('div'):addClass('bracket-title'):wikitext(title)
	end

	local tbl = root:tag('table'):addClass('bracket-table')

	local headerRow = tbl:tag('tr'):addClass('bracket-header')
	for r = 1, n do
		headerRow:tag('th'):attr('colspan', 2):wikitext(roundNames[r])
	end
	if showChampion then
		headerRow:tag('th'):wikitext('Чемпион')
	end

	local baseRows = 2 ^ n
	local finalWinner = matchWinner(matches, n, 1)
	local championName
	if finalWinner then
		championName = resolveTeam(matches, n, 1, finalWinner)
	end

	for row = 1, baseRows do
		local tr = tbl:tag('tr')
		for r = 1, n do
			local slot, teamIndex, isStart, rowspan = blockInfo(row, r)
			if isStart then
				local m = matches[r][slot]
				local name = displayName(matches, r, slot, teamIndex)
				local w = matchWinner(matches, r, slot)

				local nameCell = tr:tag('td'):addClass('bracket-team')
				if w == teamIndex then
					nameCell:addClass('bracket-winner')
				end
				nameCell:wikitext(name)
				if rowspan > 1 then nameCell:attr('rowspan', rowspan) end

				local scoreCell = tr:tag('td'):addClass('bracket-score')
				scoreCell:wikitext(formatScore(m, teamIndex))
				if rowspan > 1 then scoreCell:attr('rowspan', rowspan) end
			end
		end
		if showChampion and row == 1 then
			tr:tag('td'):addClass('bracket-champion')
				:attr('rowspan', baseRows)
				:wikitext(championName or '—')
		end
	end

	if showThird then
		local wrap = root:tag('div'):addClass('bracket-third')
		wrap:tag('div'):addClass('bracket-third-title'):wikitext(thirdName)
		local ttbl = wrap:tag('table'):addClass('bracket-third-table')
		local w3 = simpleWinner(tp)
		for i = 1, 2 do
			local trr = ttbl:tag('tr')
			local nameCell = trr:tag('td'):addClass('bracket-team')
			if w3 == i then nameCell:addClass('bracket-winner') end
			nameCell:wikitext(tp['team' .. i] or '—')
			trr:tag('td'):addClass('bracket-score'):wikitext(formatScore(tp, i))
		end
	end

	return tostring(root)
end

function p.main(frame)
	local parent = frame:getParent()
	local args = (parent and parent.args) or frame.args
	return p._main(args)
end

return p