Модуль:StatEngine/RatingBuilder: различия между версиями

Материал из ЧТМ
Перейти к навигации Перейти к поиску
Нет описания правки
Нет описания правки
Строка 1: Строка 1:
-- =========================================================================
-- =========================================================================
-- Модуль:StatEngine/RatingBuilder
-- Модуль:StatEngine/RatingBuilder
-- =========================================================================
-- =========================================================================
-- Модуль:StatEngine/RatingBuilder (Версия 6.0)
-- =========================================================================
-- =========================================================================


Строка 14: Строка 10:
-- =========================================================
-- =========================================================
local ranks = {
local ranks = {
    -- Самые базовые вылеты (0 очков)
     ["оп"] = 1,
     ["оп"] = 1,
     ["о"] = 2, ["о1"] = 3, ["о2"] = 4, ["о3"] = 5,
     ["о"] = 2, ["о1"] = 3, ["о2"] = 4, ["о3"] = 5,
   
    -- Халявщики (0 очков, но ключ должен сохраниться, поэтому ранг чуть выше базовых)
     ["о1-3г-no"] = 10, ["о-8г-no"] = 11, ["о-7г-no"] = 12, ["о-6г-no"] = 13,
     ["о1-3г-no"] = 10, ["о-8г-no"] = 11, ["о-7г-no"] = 12, ["о-6г-no"] = 13,
     ["о-5г-no"] = 14, ["о-4г-no"] = 15, ["о-3г-no"] = 16,
     ["о-5г-no"] = 14, ["о-4г-no"] = 15, ["о-3г-no"] = 16,
   
    -- Группы отборов (Раунд 1)
     ["о1-6г"] = 20, ["о1-5г"] = 21, ["о1-4г"] = 22, ["о1-3г"] = 23, ["о1-2г"] = 24, ["о1-1г"] = 25,
     ["о1-6г"] = 20, ["о1-5г"] = 21, ["о1-4г"] = 22, ["о1-3г"] = 23, ["о1-2г"] = 24, ["о1-1г"] = 25,
    -- Группы отборов (Раунд 2)
     ["о2-6г"] = 30, ["о2-5г"] = 31, ["о2-4г"] = 32, ["о2-3г"] = 33, ["о2-2г"] = 34, ["о2-1г"] = 35,
     ["о2-6г"] = 30, ["о2-5г"] = 31, ["о2-4г"] = 32, ["о2-3г"] = 33, ["о2-2г"] = 34, ["о2-1г"] = 35,
    -- Группы отборов (Раунд 3)
     ["о3-6г"] = 40, ["о3-5г"] = 41, ["о3-4г"] = 42, ["о3-3г"] = 43, ["о3-2г"] = 44, ["о3-1г"] = 45,
     ["о3-6г"] = 40, ["о3-5г"] = 41, ["о3-4г"] = 42, ["о3-3г"] = 43, ["о3-2г"] = 44, ["о3-1г"] = 45,
    -- Группы отборов (Единый раунд)
     ["о-8г"] = 50, ["о-7г"] = 51, ["о-6г"] = 52, ["о-5г"] = 53, ["о-4г"] = 54, ["о-3г"] = 55, ["о-2г"] = 56, ["о-1г"] = 57,
     ["о-8г"] = 50, ["о-7г"] = 51, ["о-6г"] = 52, ["о-5г"] = 53, ["о-4г"] = 54, ["о-3г"] = 55, ["о-2г"] = 56, ["о-1г"] = 57,
   
    -- Стыковые матчи
     ["ос"] = 60, ["ос-5г"] = 61, ["ос-4г"] = 62, ["ос-3г"] = 63, ["ос-2г"] = 64, ["ос-1г"] = 65,
     ["ос"] = 60, ["ос-5г"] = 61, ["ос-4г"] = 62, ["ос-3г"] = 63, ["ос-2г"] = 64, ["ос-1г"] = 65,
   
    -- Финальные турниры: Прямые места
     ["8"] = 70, ["7"] = 71, ["6"] = 72, ["5"] = 73,
     ["8"] = 70, ["7"] = 71, ["6"] = 72, ["5"] = 73,
    -- Финальные турниры: Места в группах
     ["6г"] = 80, ["5г"] = 81, ["4г"] = 82, ["3г"] = 83, ["2г"] = 84, ["1г"] = 85,
     ["6г"] = 80, ["5г"] = 81, ["4г"] = 82, ["3г"] = 83, ["2г"] = 84, ["1г"] = 85,
    -- Финальные турниры: Плей-офф
     ["в"] = 90, ["чф"] = 91, ["д"] = 92, ["б"] = 93, ["ф"] = 94, ["ч"] = 95
     ["в"] = 90, ["чф"] = 91, ["д"] = 92, ["б"] = 93, ["ф"] = 94, ["ч"] = 95
}
-- =========================================================
-- РУЧНЫЕ ПЕРЕОПРЕДЕЛЕНИЯ (КОСТЫЛИ)
-- Формат: [год] = { ["Турнир"] = { ["Команда"] = "Ключ" } }
-- =========================================================
-- иногда логика настолько сложна, что ГОРАЗДО проще написать руками
local manual_overrides = {
    [2009] = {
        ["КОк"] = {
        }
    },
    [2013] = {
        ["КОк"] = {
            ["НАУ"] = "ос",
            ["НОР"] = "ос",
            ["ПАП"] = "ос",
            ["МАР"] = "ос",
            ["ОКУ"] = "ос",
        },
        ["КАм"] = {
            -- Исключения для КАм-2013
        }
    },
    [2017] = {
        ["КОк"] = {
            -- Исключения для 2017 (если были)
        }
    }
}
}


Строка 108: Строка 119:
             local prev = teams[i-1]
             local prev = teams[i-1]
             if t_info.pts == prev.pts and t_info.gd == prev.gd and t_info.gf == prev.gf then
             if t_info.pts == prev.pts and t_info.gd == prev.gd and t_info.gf == prev.gf then
                -- Абсолютное равенство: оставляем тот же индекс (обе получат 8 место, например)
             else
             else
                 current_idx = current_idx + 1
                 current_idx = current_idx + 1
Строка 166: Строка 176:
     -- 1. ГРУППЫ
     -- 1. ГРУППЫ
     for stage_name, s_data in pairs(stages) do
     for stage_name, s_data in pairs(stages) do
        -- Игнорируем пульки вроде 3rdPlacesTournament
         if s_data.type == "group" and string.match(stage_name, "Group") then
         if s_data.type == "group" and string.match(stage_name, "Group") then
             for place, row in ipairs(s_data.standings) do
             for place, row in ipairs(s_data.standings) do
Строка 249: Строка 258:
         local team_results = process_tournament(full_tourney_name, stages, global_playoff_participants[base_tourney])
         local team_results = process_tournament(full_tourney_name, stages, global_playoff_participants[base_tourney])
          
          
         -- ================= ХУКИ =================
         -- ХУКИ
         if year == 2009 and base_tourney == "КОк" then
         if year == 2009 and base_tourney == "КОк" then
             assign_places_by_stats(team_results, "2г", {"б", "д"}, stages)
             assign_places_by_stats(team_results, "2г", {"б", "д"}, stages)
Строка 296: Строка 305:
         end
         end
          
          
        -- Сохраняем результаты турнира
         for t, k in pairs(team_results) do
         for t, k in pairs(team_results) do
             update_result(output[base_tourney], t, k)
             update_result(output[base_tourney], t, k)
Строка 302: Строка 310:
     end
     end
      
      
     -- ================= ИМПОРТ ОТБОРОВ =================
     -- ================= ИМПОРТ ОТБОРОВ (ТОЛЬКО ДЛЯ ТЕХ, У КОГО НЕ БЫЛО СВОИХ) =================
    -- Теперь мы импортируем отборы ЧТМ только для КАф, КАм и КЕв. КОк имеет свои Qual-базы, поэтому её не трогаем.
     if year == 2009 or year == 2013 or year == 2017 then
     if year == 2009 or year == 2013 or year == 2017 then
         local next_year = year + 1
         local next_year = year + 1
Строка 309: Строка 318:
             local dummy_playoffs = {}
             local dummy_playoffs = {}
             local ctm_results = process_tournament("ЧТМ_"..next_year.."_Qual", next_db["ЧТМ_"..next_year.."_Qual"], dummy_playoffs)
             local ctm_results = process_tournament("ЧТМ_"..next_year.."_Qual", next_db["ЧТМ_"..next_year.."_Qual"], dummy_playoffs)
             local conf_to_cup = { ["Америка"] = "КАм", ["Африка"] = "КАф", ["Евразия"] = "КЕв", ["Океания"] = "КОк" }
             local conf_to_cup = { ["Америка"] = "КАм", ["Африка"] = "КАф", ["Евразия"] = "КЕв" } -- КОк удален из импорта!
              
              
             for team, res_key in pairs(ctm_results) do
             for team, res_key in pairs(ctm_results) do
Строка 317: Строка 326:
                     if base_tourney then
                     if base_tourney then
                         if not output[base_tourney] then output[base_tourney] = {} end
                         if not output[base_tourney] then output[base_tourney] = {} end
                        if not global_playoff_participants[base_tourney] then global_playoff_participants[base_tourney] = {} end
                          
                          
                        -- Если команда играла в своих стыках (КАм_Qual), применяем правило "ос"
                         local final_key = res_key
                         local final_key = res_key
                         if global_playoff_participants[base_tourney][team] then
                         if global_playoff_participants[base_tourney] and global_playoff_participants[base_tourney][team] then
                             if res_key == "о" or res_key == "оп" or string.match(res_key, "^о%d$") then final_key = "ос"
                             if res_key == "о" or res_key == "оп" or string.match(res_key, "^о%d$") then final_key = "ос"
                             else final_key = string.gsub(res_key, "^о%d?%-", "ос-") end
                             else final_key = string.gsub(res_key, "^о%d?%-", "ос-") end
Строка 327: Строка 336:
                     end
                     end
                 end
                 end
            end
        end
    end
    -- ================= ПРИМЕНЕНИЕ РУЧНЫХ ПЕРЕОПРЕДЕЛЕНИЙ =================
    if manual_overrides[year] then
        for tourney, teams in pairs(manual_overrides[year]) do
            if not output[tourney] then output[tourney] = {} end
            for team, key in pairs(teams) do
                output[tourney][team] = key -- Жесткая перезапись
             end
             end
         end
         end

Версия от 23:25, 30 апреля 2026

Документация Документация

Сейчас проводятся усиленные тесты на странице RatingBuilder. Пока модуль категорически не готов к использованию, поскольку требует очень серьёзной отладки, но потенциал у него колоссальный.

Скрипты для отладки:

  • StatEngine/Tester — для сравнения со старой БД Data/Rating.
    • Вызов: {{#invoke:StatEngine/Tester|test_year|ГОД}}
  • StatEngine/RatingBuilder/Debug — для тупого вывода всех результатов за указанный год.
    • Вызов: {{#invoke:StatEngine/RatingBuilder/Debug|run|ГОД}}

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

-- =========================================================================
-- Модуль:StatEngine/RatingBuilder
-- =========================================================================

local Builder = {}
local TeamsDB = require('Модуль:Data/Teams')

-- =========================================================
-- ЖЕСТКАЯ ЛЕСТНИЦА РАНГОВ
-- =========================================================
local ranks = {
    ["оп"] = 1,
    ["о"] = 2, ["о1"] = 3, ["о2"] = 4, ["о3"] = 5,
    ["о1-3г-no"] = 10, ["о-8г-no"] = 11, ["о-7г-no"] = 12, ["о-6г-no"] = 13,
    ["о-5г-no"] = 14, ["о-4г-no"] = 15, ["о-3г-no"] = 16,
    ["о1-6г"] = 20, ["о1-5г"] = 21, ["о1-4г"] = 22, ["о1-3г"] = 23, ["о1-2г"] = 24, ["о1-1г"] = 25,
    ["о2-6г"] = 30, ["о2-5г"] = 31, ["о2-4г"] = 32, ["о2-3г"] = 33, ["о2-2г"] = 34, ["о2-1г"] = 35,
    ["о3-6г"] = 40, ["о3-5г"] = 41, ["о3-4г"] = 42, ["о3-3г"] = 43, ["о3-2г"] = 44, ["о3-1г"] = 45,
    ["о-8г"] = 50, ["о-7г"] = 51, ["о-6г"] = 52, ["о-5г"] = 53, ["о-4г"] = 54, ["о-3г"] = 55, ["о-2г"] = 56, ["о-1г"] = 57,
    ["ос"] = 60, ["ос-5г"] = 61, ["ос-4г"] = 62, ["ос-3г"] = 63, ["ос-2г"] = 64, ["ос-1г"] = 65,
    ["8"] = 70, ["7"] = 71, ["6"] = 72, ["5"] = 73,
    ["6г"] = 80, ["5г"] = 81, ["4г"] = 82, ["3г"] = 83, ["2г"] = 84, ["1г"] = 85,
    ["в"] = 90, ["чф"] = 91, ["д"] = 92, ["б"] = 93, ["ф"] = 94, ["ч"] = 95
}

-- =========================================================
-- РУЧНЫЕ ПЕРЕОПРЕДЕЛЕНИЯ (КОСТЫЛИ)
-- Формат: [год] = { ["Турнир"] = { ["Команда"] = "Ключ" } }
-- =========================================================
-- иногда логика настолько сложна, что ГОРАЗДО проще написать руками
local manual_overrides = {
    [2009] = {
        ["КОк"] = {
        }
    },
    [2013] = {
        ["КОк"] = {
            ["НАУ"] = "ос",
            ["НОР"] = "ос",
            ["ПАП"] = "ос",
            ["МАР"] = "ос",
            ["ОКУ"] = "ос",
        },
        ["КАм"] = {
            -- Исключения для КАм-2013
        }
    },
    [2017] = {
        ["КОк"] = {
            -- Исключения для 2017 (если были)
        }
    }
}

-- =========================================================
-- УТИЛИТЫ
-- =========================================================
local function get_match_result(m, rounds)
    local t1, t2 = m[1], m[2]
    if rounds == 1 then
        local g1, g2 = m[3] or 0, m[4] or 0
        if g1 > g2 then return t1, t2 end
        if g2 > g1 then return t2, t1 end
        local p1, p2 = m[6] or 0, m[7] or 0
        if p1 > p2 then return t1, t2 end
        if p2 > p1 then return t2, t1 end
    else
        local g1_agg = (m[3] or 0) + (m[5] or 0)
        local g2_agg = (m[4] or 0) + (m[6] or 0)
        if g1_agg > g2_agg then return t1, t2 end
        if g2_agg > g1_agg then return t2, t1 end
        local p1, p2 = m[8] or 0, m[9] or 0
        if p1 > p2 then return t1, t2 end
        if p2 > p1 then return t2, t1 end
    end
    return nil, nil
end

local function get_group_stats(team, stages)
    local pts, gf, ga = 0, 0, 0
    for stage_name, s_data in pairs(stages) do
        if s_data.type == "group" and string.match(stage_name, "Group") then
            for _, m in ipairs(s_data.matches) do
                if m[1] == team then
                    local g1, g2 = m[3] or 0, m[4] or 0
                    gf = gf + g1; ga = ga + g2
                    if g1 > g2 then pts = pts + 3 elseif g1 == g2 then pts = pts + 1 end
                elseif m[2] == team then
                    local g1, g2 = m[4] or 0, m[3] or 0
                    gf = gf + g1; ga = ga + g2
                    if g1 > g2 then pts = pts + 3 elseif g1 == g2 then pts = pts + 1 end
                end
            end
        end
    end
    return pts, gf - ga, gf
end

local function assign_places_by_stats(team_results, source_key, target_keys, stages)
    local teams = {}
    for t, res in pairs(team_results) do
        if res == source_key then
            local pts, gd, gf = get_group_stats(t, stages)
            table.insert(teams, {name = t, pts = pts, gd = gd, gf = gf})
        end
    end
    if #teams == 0 then return end
    
    table.sort(teams, function(a, b)
        if a.pts ~= b.pts then return a.pts > b.pts end
        if a.gd ~= b.gd then return a.gd > b.gd end
        if a.gf ~= b.gf then return a.gf > b.gf end
        return a.name < b.name
    end)
    
    local current_idx = 1
    for i, t_info in ipairs(teams) do
        if i > 1 then
            local prev = teams[i-1]
            if t_info.pts == prev.pts and t_info.gd == prev.gd and t_info.gf == prev.gf then
            else
                current_idx = current_idx + 1
            end
        end
        local key = target_keys[current_idx] or target_keys[#target_keys]
        team_results[t_info.name] = key
    end
end

local function update_result(tab, team, new_key)
    if not new_key then return end
    local cur_rank = tab[team] and ranks[tab[team]] or 0
    local new_rank = ranks[new_key] or 0
    if new_rank > cur_rank then tab[team] = new_key end
end

-- =========================================================
-- ОБРАБОТЧИК ОДНОГО ТУРНИРА
-- =========================================================
local function process_tournament(full_name, stages, global_playoffs)
    local team_results = {}
    local is_qual = string.match(full_name, "Qual") ~= nil
    
    local max_r_by_region = {}
    for stage_name in pairs(stages) do
        local r, reg = stage_name:match("^(%d+)R_([^_]+)")
        if not r then r = stage_name:match("^(%d+)R_"); reg = "Global" end
        if r then
            r = tonumber(r)
            if reg == "GroupA" or reg:match("Group") or reg == "Playoffs" then reg = "Global" end
            max_r_by_region[reg] = math.max(max_r_by_region[reg] or 0, r)
        end
    end

    local function get_qual_key(stage_name, place)
        local r, reg = stage_name:match("^(%d+)R_([^_]+)")
        if not r then r = stage_name:match("^(%d+)R_"); reg = "Global" end
        if reg == "GroupA" or (reg and reg:match("Group")) then reg = "Global" end
        
        if not r then
            return string.match(stage_name, "Playoff") and "ос" or "о"
        end
        
        r = tonumber(r)
        if r == 0 then return "оп" end
        
        local max_r = max_r_by_region[reg] or max_r_by_region["Global"] or 1
        local prefix = (max_r <= 1) and "о-" or ("о" .. r .. "-")
        
        if place then return prefix .. place .. "г" end
        
        if string.match(stage_name, "Playoff") then return "ос" end
        return "о" .. r
    end

    -- 1. ГРУППЫ
    for stage_name, s_data in pairs(stages) do
        if s_data.type == "group" and string.match(stage_name, "Group") then
            for place, row in ipairs(s_data.standings) do
                local team = row[1]
                local key = ""
                
                if not is_qual then
                    key = place .. "г"
                else
                    key = get_qual_key(stage_name, place)
                    if key and key ~= "оп" and #s_data.standings == 3 and place == 3 then
                        local pts = get_group_stats(team, {[stage_name] = s_data})
                        local req_pts = (s_data.number_of_rounds == 1) and 2 or 4
                        if pts < req_pts then key = key .. "-no" end
                    end
                end
                update_result(team_results, team, key)
            end
        end
    end
    
    -- 2. ПЛЕЙ-ОФФ
    local has_3rd_place_match = stages["FR_3rdPlace"] ~= nil
    for stage_name, s_data in pairs(stages) do
        if s_data.type == "knockout" then
            local is_playoff = string.match(stage_name, "Playoff") ~= nil
            
            for _, match in ipairs(s_data.matches) do
                local winner, loser = get_match_result(match, s_data.number_of_rounds)
                if winner and loser then
                    if is_qual then
                        if is_playoff then
                            global_playoffs[winner] = true
                            global_playoffs[loser] = true
                            if not team_results[loser] then update_result(team_results, loser, "ос") end
                        else
                            local key = get_qual_key(stage_name, nil)
                            if key then update_result(team_results, loser, key) end
                        end
                    else
                        if stage_name == "FR_Last 16" or stage_name == "FR_Last16" then update_result(team_results, loser, "в")
                        elseif stage_name == "FR_Quarterfinal" then update_result(team_results, loser, "чф")
                        elseif stage_name == "FR_3rdPlace" then update_result(team_results, winner, "б"); update_result(team_results, loser, "д")
                        elseif stage_name == "FR_Final" then update_result(team_results, winner, "ч"); update_result(team_results, loser, "ф")
                        elseif stage_name == "FR_Semifinal" and not has_3rd_place_match then update_result(team_results, loser, "б")
                        end
                    end
                end
            end
        end
    end
    
    -- 3. АПГРЕЙД СТЫКОВЫХ МАТЧЕЙ
    for t in pairs(global_playoffs) do
        if team_results[t] then
            if team_results[t] == "о" or team_results[t] == "оп" or string.match(team_results[t], "^о%d$") then
                update_result(team_results, t, "ос")
            else
                local new_key = string.gsub(team_results[t], "^о%d?%-", "ос-")
                update_result(team_results, t, new_key)
            end
        else
            update_result(team_results, t, "ос")
        end
    end
    
    return team_results
end

-- =========================================================
-- ГЛАВНАЯ ФУНКЦИЯ
-- =========================================================
function Builder.build_year(year, db_tournaments)
    local output = {} 
    local global_playoff_participants = {} 
    
    for full_tourney_name, stages in pairs(db_tournaments) do
        local base_tourney = string.match(full_tourney_name, "^([^_]+)")
        if not output[base_tourney] then output[base_tourney] = {} end
        if not global_playoff_participants[base_tourney] then global_playoff_participants[base_tourney] = {} end
        
        local team_results = process_tournament(full_tourney_name, stages, global_playoff_participants[base_tourney])
        
        -- ХУКИ
        if year == 2009 and base_tourney == "КОк" then
            assign_places_by_stats(team_results, "2г", {"б", "д"}, stages)
        end
        if base_tourney == "ККо" and (year == 2009 or year == 2017 or year == 2025 or year == 2029) then
            assign_places_by_stats(team_results, "3г", {"5", "6"}, stages)
            assign_places_by_stats(team_results, "4г", {"7", "8"}, stages)
        end
        if year == 2013 and base_tourney == "ККо" then
            if stages["FR_Playoffs"] then
                for _, m in ipairs(stages["FR_Playoffs"].matches) do
                    local _, loser = get_match_result(m, stages["FR_Playoffs"].number_of_rounds)
                    if loser then team_results[loser] = "5" end
                end
            end
            assign_places_by_stats(team_results, "2г", {"6"}, stages)
            assign_places_by_stats(team_results, "3г", {"7", "8"}, stages)
        end
        if year == 2021 and base_tourney == "ККо" then
            for t, res in pairs(team_results) do
                if res == "3г" then team_results[t] = "б"
                elseif res == "4г" then team_results[t] = "д"
                elseif res == "5г" then team_results[t] = "5"
                elseif res == "6г" then team_results[t] = "6"
                end
            end
        end
        if year == 2015 and base_tourney == "КФе" then
            if stages["FR_Stage2_Losers"] then
                for _, m in ipairs(stages["FR_Stage2_Losers"].matches) do
                    local _, loser = get_match_result(m, stages["FR_Stage2_Losers"].number_of_rounds)
                    if loser then team_results[loser] = "д" end
                end
            end
            if stages["FR_Semifinal"] then
                for _, m in ipairs(stages["FR_Semifinal"].matches) do
                    local _, loser = get_match_result(m, stages["FR_Semifinal"].number_of_rounds)
                    if loser then team_results[loser] = "б" end
                end
            end
        end
        if year == 2013 and base_tourney == "КЮжАм" then
            for t, res in pairs(team_results) do
                if res == "3г" then team_results[t] = "б" end
            end
        end
        
        for t, k in pairs(team_results) do
            update_result(output[base_tourney], t, k)
        end
    end
    
    -- ================= ИМПОРТ ОТБОРОВ (ТОЛЬКО ДЛЯ ТЕХ, У КОГО НЕ БЫЛО СВОИХ) =================
    -- Теперь мы импортируем отборы ЧТМ только для КАф, КАм и КЕв. КОк имеет свои Qual-базы, поэтому её не трогаем.
    if year == 2009 or year == 2013 or year == 2017 then
        local next_year = year + 1
        local success, next_db = pcall(require, 'Модуль:Data/Tournaments/' .. next_year)
        if success and next_db["ЧТМ_"..next_year.."_Qual"] then
            local dummy_playoffs = {}
            local ctm_results = process_tournament("ЧТМ_"..next_year.."_Qual", next_db["ЧТМ_"..next_year.."_Qual"], dummy_playoffs)
            local conf_to_cup = { ["Америка"] = "КАм", ["Африка"] = "КАф", ["Евразия"] = "КЕв" } -- КОк удален из импорта!
            
            for team, res_key in pairs(ctm_results) do
                local t_info = TeamsDB.getTeam(team)
                if t_info and t_info.conf then
                    local base_tourney = conf_to_cup[t_info.conf]
                    if base_tourney then
                        if not output[base_tourney] then output[base_tourney] = {} end
                        
                        -- Если команда играла в своих стыках (КАм_Qual), применяем правило "ос"
                        local final_key = res_key
                        if global_playoff_participants[base_tourney] and global_playoff_participants[base_tourney][team] then
                            if res_key == "о" or res_key == "оп" or string.match(res_key, "^о%d$") then final_key = "ос"
                            else final_key = string.gsub(res_key, "^о%d?%-", "ос-") end
                        end
                        update_result(output[base_tourney], team, final_key)
                    end
                end
            end
        end
    end

    -- ================= ПРИМЕНЕНИЕ РУЧНЫХ ПЕРЕОПРЕДЕЛЕНИЙ =================
    if manual_overrides[year] then
        for tourney, teams in pairs(manual_overrides[year]) do
            if not output[tourney] then output[tourney] = {} end
            for team, key in pairs(teams) do
                output[tourney][team] = key -- Жесткая перезапись
            end
        end
    end
    
    return output
end

return Builder