Модуль:Megarating

Материал из ЧТМ
Перейти к навигации Перейти к поиску
Документация Документация

Модуль для подсчёта и вывода таблиц мегарейтинга.

Главная функция, работающая на основной странице:

{{#invoke:Megarating|drawAll}}

Вспомогательная функция, выдающая сворачиваемые таблички по конкретным ЧТМ:

{{#invoke:Megarating|drawTables|years=2006,2010, 2014,2018,2022,2026,2030,2034,2038,2042,2046|expanded=no}}

Можно выбирать любые годы, перечисляя их через запятую и выводить сразу развёрнутую таблицу, задав yes параметру expanded.

Пожалуйста, добавляйте категории на страницу документации.

-- ==========================================================
-- Модуль:Megarating
-- Архитектура подсчёта мегарейтинга ЧТМ
-- Версия 1.3.
-- ==========================================================
-- ВНИМАНИЕ!!!
-- Для использования в качестве движка для других модулей
-- используйте ТОЛЬКО function Megarating.evaluate_raw_stats,
-- чтобы не гонять БД лишний раз.
-- ==========================================================

local Megarating = {}

local Config = require('Module:Config')
local StatEngine = require('Module:StatEngine')
local TournamentAwards = require('Module:StatEngine/TournamentAwards')

local POINTS_10 = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1}
local POINTS_5  = {5, 4, 3, 2, 1}

-- =========================================================
-- НАСТРОЙКИ КОЛОНОК И ИХ ЗАГОЛОВКОВ
-- =========================================================
local COLUMNS = {
    [1]  = "[[Лучший бомбардир|1]]",
    [2]  = "[[Игрок матча|2]]",
    [3]  = "[[Голы|3]]",
    [4]  = "[[Мега-трики ЧТМ|4]]",
    [5]  = "[[Лучший пенальтист|5]]",
    [6]  = "[[Отбитые пенальти|6]]",
    [7]  = "[[Игрок матча как вратарь|7]]",
    [8]  = '<abbr title="Сумма очков за признание игроком матча в плей-офф">8</abbr>',
    [9]  = '<abbr title="Сумма очков за победы в плей-офф">9</abbr>',
    [10] = "[[Процент набранных очков|10]]",
    [11] = "[[Показатель полезности|11]]",
    [12] = "[[Коэффициент пропущенных голов|12]]",
    [13] = "[[Матчи|13]]",
    [14] = "[[Голы головой|14]]",
    [15] = "[[Выносы из пустых|15]]",
    [16] = "[[Средняя результативность|16]]",
    [17] = "[[Матчи на ноль|17]]",
    [18] = "[[Лучший ассистент|18]]",
    [19] = "[[Передачи|19]]",
    [20] = "[[Голы пяточкой|20]]",
    [21] = "[[Голы со штрафных|21]]",
    [22] = '<abbr title="Голевые передачи (ср. за матч)">22</abbr>',
    [23] = "[[Мега-трики голевых передач|23]]",
    [24] = '<abbr title="Бонусные очки">Б</abbr>'
}

-- Порядок показателей для правила разрешения равенства
local TIE_BREAKER_ORDER = {1, 9, 8, 24, 18, 10, 2, 3, 19, 11, 12, 13, 7, 4, 23, 16, 22, 17, 6, 5, 15, 14, 21, 20}

-- =========================================================
-- УТИЛИТЫ ДЛЯ СОРТИРОВКИ И ОТОБРАЖЕНИЯ
-- =========================================================

local function fmt(num)
    if not num or num == 0 then return "0" end
    local s = string.format("%.2f", num)
    if s:find("%.") then
        s = s:gsub("0+$", "")
        s = s:gsub("%.$", "")
    end
    return s
end

local function get_bg_color(rank)
    if rank == 1 then return Config.styles.gold
    elseif rank == 2 then return Config.styles.silver
    elseif rank == 3 then return Config.styles.bronze
    elseif rank == 4 then return Config.styles.wood
    end
    return ""
end

-- =========================================================
-- ЛОГИКА ВЫЧИСЛЕНИЙ (ТАЙБРЕЙКЕР И АЛЛОКАЦИЯ ОЧКОВ)
-- =========================================================

local function extract_spheres(year_db)
    local spheres = {}
    for _, match in pairs(year_db) do
        if match.golden_sphere then spheres[match.golden_sphere] = 4 end
        if match.silver_sphere then spheres[match.silver_sphere] = math.max(spheres[match.silver_sphere] or 0, 3) end
        if match.bronze_sphere then spheres[match.bronze_sphere] = math.max(spheres[match.bronze_sphere] or 0, 2) end
        if match.wooden_sphere then spheres[match.wooden_sphere] = math.max(spheres[match.wooden_sphere] or 0, 1) end
    end
    return spheres
end

local function is_perfect_tie(a, b, year, spheres)
    if a.total_points ~= b.total_points then return false end
    
    local sphere_a = spheres[a.name] or 0
    local sphere_b = spheres[b.name] or 0
    if sphere_a ~= sphere_b then return false end

    for _, metric_id in ipairs(TIE_BREAKER_ORDER) do
        local valid = true
        if metric_id >= 10 and metric_id <= 17 and year < Config.eras.plus_minus then valid = false end
        if metric_id >= 18 and metric_id <= 23 and year < Config.eras.assists then valid = false end

        if valid then
            local pts_a = a.breakdown[metric_id] or 0
            local pts_b = b.breakdown[metric_id] or 0
            if pts_a ~= pts_b then return false end
        end
    end
    return true
end

local function compare_players(a, b, year, spheres)
    if not is_perfect_tie(a, b, year, spheres) then
        if a.total_points ~= b.total_points then return a.total_points > b.total_points end
        
        local sphere_a = spheres[a.name] or 0
        local sphere_b = spheres[b.name] or 0
        if sphere_a ~= sphere_b then return sphere_a > sphere_b end
        
        for _, metric_id in ipairs(TIE_BREAKER_ORDER) do
            local valid = true
            if metric_id >= 10 and metric_id <= 17 and year < Config.eras.plus_minus then valid = false end
            if metric_id >= 18 and metric_id <= 23 and year < Config.eras.assists then valid = false end

            if valid then
                local pts_a = a.breakdown[metric_id] or 0
                local pts_b = b.breakdown[metric_id] or 0
                if pts_a ~= pts_b then return pts_a > pts_b end
            end
        end
    end
    return a.name < b.name
end

local function allocate_points(sorted_list, point_scheme, val_func, add_points_func, metric_id)
    local i = 1
    while i <= #sorted_list and i <= #point_scheme do
        local current_val = val_func(sorted_list[i])
        local group = { sorted_list[i] }
        
        local j = i + 1
        while j <= #sorted_list and val_func(sorted_list[j]) == current_val do
            table.insert(group, sorted_list[j])
            j = j + 1
        end
        
        local total_pts = 0
        for k = 1, #group do
            if (i + k - 1) <= #point_scheme then total_pts = total_pts + point_scheme[i + k - 1] end
        end
        
        local shared_pts = total_pts / #group
        for _, p in ipairs(group) do
            add_points_func(p.name, metric_id, shared_pts)
        end
        i = j 
    end
end

-- =========================================================
-- ЧИСТЫЙ ДВИЖОК МЕГАРЕЙТИНГА (Не вызывает Комбайн, жрёт готовое)
-- =========================================================
function Megarating.evaluate_raw_stats(year, year_db, players)
    local results = {}
    
    for name, _ in pairs(players) do
        results[name] = { name = name, total_points = 0, breakdown = {} }
    end

    local function add_pts(name, metric_id, pts)
        if pts > 0 then
            results[name].total_points = results[name].total_points + pts
            results[name].breakdown[metric_id] = (results[name].breakdown[metric_id] or 0) + pts
        end
    end

    local function process_metric(metric_id, scheme, sort_asc, condition_func, get_val_func)
        local list = {}
        for name, p in pairs(players) do
            local val = get_val_func(p)
            if condition_func(p, val) then table.insert(list, { name = name, val = val }) end
        end
        table.sort(list, function(a, b)
            if sort_asc then return a.val < b.val else return a.val > b.val end
        end)
        allocate_points(list, scheme, function(item) return item.val end, add_pts, metric_id)
    end

    local function process_award_metric(metric_id, award_type)
        local raw_list = TournamentAwards.getTournamentAwards(year, year_db, award_type)
        local dedup_list = {}
        local seen = {}
        for _, p in ipairs(raw_list) do
            if not seen[p.player] and p.total > 0 then
                seen[p.player] = true
                table.insert(dedup_list, { name = p.player, val = p.total })
            end
        end
        table.sort(dedup_list, function(a, b)
            if a.val ~= b.val then return a.val > b.val end
            return a.name < b.name
        end)
        allocate_points(dedup_list, POINTS_10, function(item) return item.val end, add_pts, metric_id)
    end

    if year >= Config.eras.goals then process_award_metric(1, "goals") end
    if year >= Config.eras.assists then process_award_metric(18, "assists") end

    process_metric(2, POINTS_10, false, function(p, v) return v > 0 end, function(p) return p.mvp.is_mvp end)
    process_metric(3, POINTS_10, false, function(p, v) return v > 0 end, function(p) return p.goals.total end)
    
    local mt_goals_list = {}
    for name, p in pairs(players) do
        local total_mt = p.goals.hat_trick + p.goals.poker + p.goals.penta + p.goals.hexa
        if total_mt > 0 then table.insert(mt_goals_list, { name = name, total = total_mt, hexa = p.goals.hexa, penta = p.goals.penta, poker = p.goals.poker }) end
    end
    table.sort(mt_goals_list, function(a, b)
        if a.total ~= b.total then return a.total > b.total end
        if a.hexa ~= b.hexa then return a.hexa > b.hexa end
        if a.penta ~= b.penta then return a.penta > b.penta end
        if a.poker ~= b.poker then return a.poker > b.poker end
        return a.name < b.name
    end)
    allocate_points(mt_goals_list, POINTS_5, function(item) return item.total .. "_" .. item.hexa .. "_" .. item.penta .. "_" .. item.poker end, add_pts, 4)
    
    local pen_list = {}
    for name, p in pairs(players) do
        local a = p.penalties.in_game.u + p.penalties.shootout.u
        local b = p.penalties.in_game.g + p.penalties.shootout.g
        if a > 0 then table.insert(pen_list, { name = name, val = b - (a - b) * 2, scored = b }) end
    end
    table.sort(pen_list, function(a, b)
        if a.val ~= b.val then return a.val > b.val end
        if a.scored ~= b.scored then return a.scored > b.scored end
        return a.name < b.name
    end)
    allocate_points(pen_list, POINTS_5, function(item) return item.val .. "_" .. item.scored end, add_pts, 5)
    
    process_metric(6, POINTS_5, false, function(p, v) return v > 0 end, function(p) return p.penalties.saved_as_goalie end)
    process_metric(7, POINTS_5, false, function(p, v) return v > 0 end, function(p) return p.mvp.is_goalie_mvp end)

    if year >= Config.eras.plus_minus then
        process_metric(10, POINTS_10, false, function(p, v) return p.megarating.mr_matches >= 20 end, function(p) return (p.megarating.mr_points / (p.megarating.mr_matches * 3)) * 100 end)
        process_metric(11, POINTS_10, false, function(p, v) return p.megarating.mr_matches > 0 end, function(p) return p.plus_minus end)
        
        local kpg_list = {}
        for name, p in pairs(players) do
            if p.matches_goalie > 0 then table.insert(kpg_list, { name = name, val = p.weighted_ga / p.matches_goalie, matches = p.matches_goalie }) end
        end
        table.sort(kpg_list, function(a, b)
            local t_a = (a.matches >= 5) and 1 or 2
            local t_b = (b.matches >= 5) and 1 or 2
            if t_a ~= t_b then return t_a < t_b end
            if a.val ~= b.val then return a.val < b.val end
            return a.name < b.name
        end)
        allocate_points(kpg_list, POINTS_10, function(item) return ((item.matches >= 5) and 1 or 2) .. "_" .. string.format("%.3f", item.val) end, add_pts, 12)

        process_metric(13, POINTS_10, false, function(p, v) return v > 0 end, function(p) return p.matches_total end)
        process_metric(14, POINTS_5, false, function(p, v) return v > 0 end, function(p) return p.goals.head end)
        process_metric(15, POINTS_5, false, function(p, v) return v > 0 end, function(p) return p.clearances end)
        process_metric(16, POINTS_5, false, function(p, v) return p.matches_field >= 20 and p.goals.total > 0 end, function(p) return p.goals.total / p.matches_field end)
        process_metric(17, POINTS_5, false, function(p, v) return v > 0 end, function(p) return p.clean_sheets end)
    end

    if year >= Config.eras.assists then
        process_metric(19, POINTS_10, false, function(p, v) return v > 0 end, function(p) return p.assists.total end)
        process_metric(20, POINTS_5, false, function(p, v) return v > 0 end, function(p) return p.goals.heel end)
        process_metric(21, POINTS_5, false, function(p, v) return v > 0 end, function(p) return p.goals.free_kick end)
        process_metric(22, POINTS_5, false, function(p, v) return p.matches_field >= 20 and p.assists.total > 0 end, function(p) return p.assists.total / p.matches_field end)
        
        local mt_assists_list = {}
        for name, p in pairs(players) do
            local total_mt = p.assists.hat_trick + p.assists.poker + p.assists.penta
            if total_mt > 0 then table.insert(mt_assists_list, { name = name, total = total_mt, penta = p.assists.penta, poker = p.assists.poker }) end
        end
        table.sort(mt_assists_list, function(a, b)
            if a.total ~= b.total then return a.total > b.total end
            if a.penta ~= b.penta then return a.penta > b.penta end
            if a.poker ~= b.poker then return a.poker > b.poker end
            return a.name < b.name
        end)
        allocate_points(mt_assists_list, POINTS_5, function(item) return item.total .. "_" .. item.penta .. "_" .. item.poker end, add_pts, 23)
    end

    for _, p in pairs(players) do p.megarating.final_assists = 0; p.megarating.semi_assists = 0 end
    for match_id, match in pairs(year_db) do
        local st = match.stage or ""
        if (st == "Финал" or st == "Полуфинал") and match.goals then
            for _, g in ipairs(match.goals) do
                if g.assist and players[g.assist] then
                    if st == "Финал" then players[g.assist].megarating.final_assists = players[g.assist].megarating.final_assists + 1
                    else players[g.assist].megarating.semi_assists = players[g.assist].megarating.semi_assists + 1 end
                end
            end
        end
        if st == "Финал" then
            local res = StatEngine.Harvester.evaluate_match(match)
            if res then
                local win_team = (res[1].pts >= 2) and 1 or ((res[2].pts >= 2) and 2 or 0)
                if win_team > 0 then
                    local p_teams = StatEngine.Harvester.get_all_teams(match)
                    for p_name, t_id in pairs(p_teams) do
                        if t_id == win_team and players[p_name] then players[p_name].megarating.is_champion = true end
                    end
                end
            end
        end
    end

    for name, p in pairs(players) do
        local mr = p.megarating
        local p8 = mr.playoff_mvp.r16 * 1 + mr.playoff_mvp.qf * 2 + mr.playoff_mvp.sf * 3 + mr.playoff_mvp.final * 10
        if p8 > 0 then add_pts(name, 8, p8) end

        local p9 = 0
        if mr.is_champion then p9 = p9 + (year >= 2022 and 10 or 5) end
        if year >= 2022 then p9 = p9 + mr.playoff_wins.r16 * 1 + mr.playoff_wins.qf * 2 + mr.playoff_wins.sf * 3 end
        if p9 > 0 then add_pts(name, 9, p9) end

        local p24 = 0
        if mr.best_goal_bonus then p24 = p24 + 5 end
        p24 = p24 + (mr.final_goals * 2) + (mr.final_gold_goals * 3) + (mr.final_assists * 1) + (mr.final_gold_assists * 2)
        p24 = p24 + (mr.semi_goals * 1) + (mr.semi_assists * 0.5)
        if p24 > 0 then add_pts(name, 24, p24) end
    end

    local final_list = {}
    for _, r in pairs(results) do
        if r.total_points > 0 then table.insert(final_list, r) end
    end
    return final_list
end

-- =========================================================
-- ОБОЛОЧКА ДЛЯ ОБРАТНОЙ СОВМЕСТИМОСТИ (Для старых страниц)
-- =========================================================
function Megarating.evaluate_year(year, year_db)
    local stats = StatEngine.Harvester.run(year_db, {need_players = true})
    return Megarating.evaluate_raw_stats(year, year_db, stats.Players)
end

-- А также добавим функцию для извлечения сфер для тайбрейкера, чтобы Значимость могла к ней обратиться
function Megarating.get_spheres_from_db(year_db)
    return extract_spheres(year_db)
end

function Megarating.compare_players_public(a, b, year, spheres)
    return compare_players(a, b, year, spheres)
end

-- =========================================================
-- БЛОК ИСТОРИЧЕСКОГО КЭШИРОВАНИЯ (1 проход по БД)
-- =========================================================
local _history_cache = nil

local function get_history()
    if _history_cache then return _history_cache end

    local history = { players = {}, tournaments = {}, yearly_lists = {} }
    local latest_year = Config.years[#Config.years]
    local is_latest_finished = Config.is_latest_finished

    for _, year in ipairs(Config.years) do
        local is_finished = (year ~= latest_year) or is_latest_finished
        
        local success, year_db = pcall(require, 'Module:Data/' .. year)
        if success and type(year_db) == "table" then
            local spheres = extract_spheres(year_db)
            local final_list = Megarating.evaluate_year(year, year_db)
            
            if #final_list > 0 then
                table.sort(final_list, function(a, b) return compare_players(a, b, year, spheres) end)
                local max_pts = final_list[1].total_points
                
                if is_finished then history.tournaments[year] = {} end
                
                local current_rank = 1
                for i, p in ipairs(final_list) do
                    local is_tie = false
                    if i > 1 then
                        is_tie = is_perfect_tie(final_list[i-1], p, year, spheres)
                        if not is_tie then current_rank = i end
                    else
                        current_rank = 1
                    end
                    
                    p.computed_rank = current_rank
                    p.is_tie = is_tie
                    local pct = (p.total_points / max_pts) * 100
                    
                    if not history.players[p.name] then
                        history.players[p.name] = {
                            name = p.name, total_pct = 0, years = {},
                            places = { [1]=0, [2]=0, [3]=0, [4]=0 },
                            all_places = {}, top5 = 0, top10 = 0
                        }
                    end
                    
                    local hp = history.players[p.name]
                    hp.years[year] = { pct = pct, rank = current_rank, pts = p.total_points, is_finished = is_finished }
                    hp.total_pct = hp.total_pct + pct
                    
                    if is_finished then
                        hp.all_places[current_rank] = (hp.all_places[current_rank] or 0) + 1
                        if current_rank <= 4 then hp.places[current_rank] = hp.places[current_rank] + 1 end
                        if current_rank <= 5 then hp.top5 = hp.top5 + 1 end
                        if current_rank <= 10 then hp.top10 = hp.top10 + 1 end
                        
                        if current_rank <= 4 then
                            table.insert(history.tournaments[year], { name = p.name, pts = p.total_points })
                        end
                    end
                end
                
                history.yearly_lists[year] = final_list
            end
        end
    end
    
    _history_cache = history
    return _history_cache
end

-- =========================================================
-- ВНУТРЕННИЕ ГЕНЕРАТОРЫ СТРОК ДЛЯ БЛОКОВ (html)
-- =========================================================

local function generate_historical_table(hist)
    local list = {}
    for _, p in pairs(hist.players) do table.insert(list, p) end
    table.sort(list, function(a, b)
        if math.abs(a.total_pct - b.total_pct) > 1e-6 then return a.total_pct > b.total_pct end
        return a.name < b.name
    end)

    if #list == 0 then return "Данные не найдены." end

    local columns = { "Место", "Игрок" }
    for _, y in ipairs(Config.years) do
        local short_y = "'" .. tostring(y):sub(3,4)
        table.insert(columns, "[[" .. y .. "|" .. short_y .. "]]")
    end
    table.insert(columns, "ВСЕГО")

    local html = Config.builder.start(columns)
    html:css('width', '500px')

    local current_rank = 1
    for i, p in ipairs(list) do
        local is_tie = false
        if i > 1 and math.abs(list[i-1].total_pct - p.total_pct) <= 1e-6 then is_tie = true else current_rank = i end

        local cells = {
            is_tie and "" or tostring(current_rank),
            "[[" .. p.name .. "]]"
        }
        
        for _, y in ipairs(Config.years) do
            local y_data = p.years[y]
            if y_data then
                local bg = y_data.is_finished and get_bg_color(y_data.rank) or ""
                table.insert(cells, {text = fmt(y_data.pct), style = Config.styles.center_nowrap .. bg})
            else
                table.insert(cells, "0")
            end
        end
        
        table.insert(cells, "'''" .. fmt(p.total_pct) .. "'''")
        Config.builder.row(html, cells)
    end
    
    return tostring(html)
end

local function generate_tournament_leaders(hist)
    local columns = { "ЧТМ", "", "1", "2", "3", "4" }
    local html = Config.builder.start(columns)
    html:css('width', '500px')

    local latest_year = Config.years[#Config.years]
    local is_latest_finished = Config.is_latest_finished

    for _, year in ipairs(Config.years) do
        local is_finished = (year ~= latest_year) or is_latest_finished
        if is_finished then
            local top = hist.tournaments[year]
            if top and #top > 0 then
                local cells = {
                    "[[" .. year .. "]]"
                }
                local champ_file = top[1].name:gsub("%.$", "")
                table.insert(cells, {text = '[[Файл:Кв-' .. champ_file .. '.png|120px]]', style = Config.styles.center_nowrap .. Config.styles.gold})
                
                for i = 1, 4 do
                    local bg = get_bg_color(i)
                    local content = top[i] and ("'''[[" .. top[i].name .. "]]''' &nbsp;— '''" .. fmt(top[i].pts) .. "''' ") or ""
                    table.insert(cells, {text = content, style = Config.styles.center_nowrap .. bg})
                end
                Config.builder.row(html, cells)
            end
        end
    end
    
    return tostring(html)
end

local function generate_medal_standings(hist)
    local list = {}
    for _, p in pairs(hist.players) do
        local sum_medals = p.places[1] + p.places[2] + p.places[3] + p.places[4]
        if sum_medals > 0 then table.insert(list, p) end
    end
    
    table.sort(list, function(a, b)
        for r = 1, 4 do
            if a.places[r] ~= b.places[r] then return a.places[r] > b.places[r] end
        end
        for r = 5, 100 do
            local a_c = a.all_places[r] or 0
            local b_c = b.all_places[r] or 0
            if a_c ~= b_c then return a_c > b_c end
        end
        return a.name < b.name
    end)

    local columns = { "", "Игрок", "1", "2", "3", "4", "Всего" }
    local html = Config.builder.start(columns)
    html:css('width', '500px')

    local current_rank = 1
    for i, p in ipairs(list) do
        local is_tie = false
        if i > 1 then
            local prev = list[i-1]
            local exact_match = true
            for r = 1, 100 do
                local a_c = p.all_places[r] or 0
                local b_c = prev.all_places[r] or 0
                if a_c ~= b_c then exact_match = false; break end
            end
            if exact_match then is_tie = true else current_rank = i end
        else current_rank = 1 end

        local cells = {
            is_tie and "" or tostring(current_rank),
            "[[" .. p.name .. "]]"
        }
        
        for r = 1, 4 do
            local val = p.places[r]
            local bg = (val > 0) and get_bg_color(r) or ""
            local text = (val > 0) and tostring(val) or "0"
            table.insert(cells, {text = text, style = Config.styles.center_nowrap .. bg})
        end
        
        local total = p.places[1] + p.places[2] + p.places[3] + p.places[4]
        table.insert(cells, "'''" .. total .. "'''")
        Config.builder.row(html, cells)
    end

    return tostring(html)
end

local function generate_top_list(hist, min_rank)
    local counts = {}
    for _, p in pairs(hist.players) do
        local count = (min_rank == 5) and p.top5 or p.top10
        if count > 0 then
            if not counts[count] then counts[count] = {} end
            table.insert(counts[count], p.name)
        end
    end
    
    local sorted_counts = {}
    for c, _ in pairs(counts) do table.insert(sorted_counts, c) end
    table.sort(sorted_counts, function(a, b) return a > b end)
    
    local out = {}
    for _, c in ipairs(sorted_counts) do
        local names = counts[c]
        table.sort(names)
        local linked = {}
        for _, n in ipairs(names) do table.insert(linked, "[[" .. n .. "]]") end
        
        local ending = "раз"
        local last_digit = c % 10
        local last_two = c % 100
        if last_digit >= 2 and last_digit <= 4 and (last_two < 10 or last_two >= 20) then ending = "раза" end
        
        local last_name = names[#names]
        local end_char = (last_name:sub(-1) == ".") and "" or "."
        
        table.insert(out, "* " .. c .. " " .. ending .. ": " .. table.concat(linked, ", ") .. end_char)
    end
    return table.concat(out, "\n")
end

-- =========================================================
-- ЧИСТЫЙ ГЕНЕРАТОР ТАБЛИЦЫ КОНКРЕТНОГО ГОДА (БЕЗ ОБРАЩЕНИЯ К БД)
-- Использует новый Config.builder.start_collapsible
-- =========================================================
function Megarating.buildYearTable(year, final_list, expanded)
    if not final_list or #final_list == 0 then return "" end

    local max_pts = final_list[1].total_points
    local max_col = 9
    if year >= Config.eras.assists then max_col = 23
    elseif year >= Config.eras.plus_minus then max_col = 17 end

    -- Формируем колонки для шапки
    local columns = {"", "Игрок"}
    for i = 1, max_col do table.insert(columns, COLUMNS[i]) end
    table.insert(columns, COLUMNS[24])
    table.insert(columns, '<abbr title="Сумма очков">ВСЕГО</abbr>')
    table.insert(columns, '<abbr title="Процент от суммы очков, набранных лидером">%</abbr>')

    local title = "[[" .. year .. "]]"
    local html = Config.builder.start_collapsible(columns, title, expanded)

    -- Наполняем таблицу строками
    for _, p in ipairs(final_list) do
        local cells = {
            p.is_tie and "" or tostring(p.computed_rank),
            "[[" .. p.name .. "]]"
        }
        
        for col = 1, max_col do table.insert(cells, fmt(p.breakdown[col])) end
        table.insert(cells, fmt(p.breakdown[24]))
        table.insert(cells, "'''" .. fmt(p.total_points) .. "'''")
        
        local pct = (p.total_points / max_pts) * 100
        table.insert(cells, fmt(pct))
        
        Config.builder.row(html, cells)
    end
    
    return tostring(html)
end

local function generate_all_yearly_tables(hist)
    local out = {}
    for _, year in ipairs(Config.years) do
        local final_list = hist.yearly_lists[year]
        if final_list and #final_list > 0 then
            -- Вызываем универсальный генератор с флагом false (свернута)
            local tbl_string = Megarating.buildYearTable(year, final_list, false)
            table.insert(out, tbl_string)
        end
    end
    return table.concat(out, "\n\n")
end

-- =========================================================
-- ЕДИНЫЙ ПУБЛИЧНЫЙ ВЫЗОВ (1 проход БД на всю страницу!)
-- Вызов: {{#invoke:Megarating|drawAll}}
-- =========================================================
function Megarating.drawAll(frame)
    local hist = get_history()
    local out = {}

    table.insert(out, "== Исторический мегарейтинг ==")
    table.insert(out, generate_historical_table(hist))

    table.insert(out, "== Лидеры мегарейтинга по турнирам ==")
    table.insert(out, generate_tournament_leaders(hist))

    table.insert(out, "== Статистика игроков ==")
    table.insert(out, "=== Призовые места ===")
    table.insert(out, generate_medal_standings(hist))

    table.insert(out, "=== Места в пятёрке ===")
    table.insert(out, generate_top_list(hist, 5))

    table.insert(out, "=== Места в десятке ===")
    table.insert(out, generate_top_list(hist, 10))

    table.insert(out, "== Мегарейтинг по чемпионатам ==")
    table.insert(out, generate_all_yearly_tables(hist))

    -- Возвращаем всё с обязательной пред-загрузкой стилей Config
    return frame:preprocess(Config.styles.wiki_templates .. "\n" .. table.concat(out, "\n\n"))
end

-- =========================================================
-- ОДИНОЧНЫЙ ВЫЗОВ ДЛЯ СТРАНИЦ КОНКРЕТНЫХ ТУРНИРОВ
-- Вызов: {{#invoke:Megarating|drawTables|years=2038|expanded=yes}}
-- =========================================================
function Megarating.drawTables(frame)
    local args = frame.args
    local input_years = args.years or tostring(Config.years[#Config.years])
    local expanded = args.expanded == "yes" or args.expanded == "true"
    
    local target_years = {}
    if input_years == "all" then target_years = Config.years
    else for y in string.gmatch(input_years, "%d+") do table.insert(target_years, tonumber(y)) end end
    table.sort(target_years)

    local output = {}

    for _, year in ipairs(target_years) do
        local success, year_db = pcall(require, 'Module:Data/' .. year)
        if success and type(year_db) == "table" then
            local spheres = extract_spheres(year_db)
            local final_list = Megarating.evaluate_year(year, year_db)
            
            if #final_list > 0 then
                table.sort(final_list, function(a, b) return compare_players(a, b, year, spheres) end)
                
                -- Подсчитываем ранги
                local current_rank = 1
                for i, p in ipairs(final_list) do
                    local is_tie = false
                    if i > 1 then
                        is_tie = is_perfect_tie(final_list[i-1], p, year, spheres)
                        if not is_tie then current_rank = i end
                    else current_rank = 1 end
                    p.computed_rank = current_rank
                    p.is_tie = is_tie
                end

                -- ВЫЗЫВАЕМ НАШУ ЧИСТУЮ ФУНКЦИЮ
                local generated_table = Megarating.buildYearTable(year, final_list, expanded)
                table.insert(output, generated_table)
            end
        end
    end

    if #output == 0 then return "Данные не найдены." end
    
    -- Возвращаем таблицы, подклеив стили в начало
    return frame:preprocess(Config.styles.wiki_templates .. "\n" .. table.concat(output, "\n\n"))
end

-- Экспорт кэша для Модуля Значимости
function Megarating.get_public_history()
    return get_history()
end

return Megarating