OWT нужны функциональные cookie, чтобы сохранять вход и язык интерфейса. Аналитические cookie необязательны и только показывают, как используются страницы, — подробнее в политике конфиденциальности.

Перейти к содержимому

Турниры, стадии и сетка

Турнир — это корневой объект соревнования внутри воркспейса: у него собственный жизненный цикл, расписание фаз, набор стадий и вычисляемая таблица. Эта статья описывает модель так, как её видит интегратор: какие состояния бывают, что их меняет, как устроена сетка и что именно считается в таблице.

Жизненный цикл

Статус турнира — поле status со значениями announcement, registration, check_in, draft, live, playoffs, completed, archived. У фаз есть канонический порядок 0…7 именно в этом перечислении; автоматика двигается только вперёд по нему.

Переходы проверяются по матрице: всё, чего в ней нет, отклоняется.

ИзРазрешённые цели
announcementregistration, check_in, draft, live
registrationcheck_in, draft, live, announcement
check_indraft, live, registration
draftlive, check_in, registration
liveplayoffs, completed, draft, check_in
playoffscompleted
completedarchived
archivedcompleted

Из матрицы следуют три правила, на которые можно опираться. Скачки вперёд легальны: registration → live — один переход, промежуточные фазы не обязательны. Откат назад существует вплоть до live, чтобы организатор мог переоткрыть фазу без привилегированного обхода. А playoffs ведёт только в completed: назад из плей-офф пути нет.

Поле is_finished хранится в базе, но выводится из статуса: оно истинно ровно для completed и archived, и переписывается на каждом переходе. Фильтровать по нему можно, считать независимым флагом — нет. «Идёт игра» — это live и playoffs; check_in сюда не входит, это ещё лобби.

Расписание фаз

У турнира есть до четырёх строк расписания: registration, check_in, draft, live. announcement, playoffs, completed и archived расписанию не подлежат — они зависят от хода игры, а не от часов. На фазу приходится ровно одна строка, у строки есть starts_at и необязательный ends_at, причём база требует ends_at строго позже starts_at.

Смысл двух полей разный, и это главное, что нужно запомнить:

  • starts_at двигает статус. Наступил момент — фаза становится текущей.
  • ends_at статус не двигает никогда. Он только закрывает окно действий этой фазы — например, чек-ин закрывается за пятнадцать минут до начала игр, но турнир при этом остаётся в check_in, пока его не переведут дальше.

Расписание отдаётся вместе с турниром в поле phase_schedule, записывается целиком: PUT /api/v1/admin/tournaments/{tournament_id}/schedule.

Что меняет статус

Ровно две вещи.

Администратор — PATCH /api/v1/admin/tournaments/{tournament_id}/status. Переход проверяется по матрице, если не передан force (обход для суперпользователя). Вход в registration дополнительно требует сохранённой формы регистрации, иначе 409 с кодом registration_form_missing: статус — это то, что читает кнопка записи, и объявлять запись без формы нельзя. Любой ручной переход в той же транзакции выключает auto_transitions_enabled, чтобы автоматика не спорила с решением организатора.

Тик сервиса турниров, раз в 30 секунд. Он двигает только вперёд, только из announcement, registration, check_in, draft и максимум до live. Если наступило сразу несколько строк расписания, турнир переводится в самую позднюю наступившую фазу, а не прогоняется по промежуточным. Каждый турнир обрабатывается в собственной транзакции под блокировкой строки, поэтому сбой на одном не мешает остальным. Турниры с auto_transitions_enabled = false тик не трогает.

У входа в live есть побочный эффект: автоматически стартует первая волна групповых стадий. Волна — это все незавершённые групповые стадии с минимальным значением order; стадии с одинаковым order считаются одной параллельной фазой (например, два дивизиона) и стартуют вместе. Если у стадии уже есть встречи, она просто активируется; если нет — ставится задача на генерацию сетки.

Видимость

Флаг is_hidden ортогонален статусу. Скрытый турнир и всё вложенное в него отвечают 404 «не найдено» — не 403, чтобы существование события нельзя было подтвердить по коду ответа. Видят его администраторы воркспейса и пользователи из списка предпросмотра (GET и POST /api/v1/admin/tournaments/{tournament_id}/preview-access, удаление — DELETE .../preview-access/{auth_user_id}).

Отдельно проверяется скрытость самого воркспейса: турнир, скрытый только из-за неё, доступен любому участнику воркспейса, а не только администраторам.

Единственное исключение из правила предпросмотра — контейнер скримов: скрытый служебный турнир, по одному на воркспейс, внутри которого живут комнаты скримов. Его читает любой участник воркспейса, потому что соперник должен открыть комнату по ссылке до того, как заявит за себя сторону. В списках турниров контейнер при этом не появляется никогда. Подробнее о комнатах — в статье Встречи, результаты и логи.

Стадии

Стадия — это один соревновательный отрезок турнира. Тип задаётся полем stage_type: round_robin, single_elimination, double_elimination, swiss, ffa_league.

Отдельного поля состояния у стадии нет — оно выводится из трёх булевых флагов, которые отдаёт API:

СостояниеУсловие
Черновиквстреч ещё нет
Предпросмотрвстречи сгенерированы, но is_published ещё false
В игреis_published или is_active
Завершенаis_completed

Разделение is_active и is_published важно для интегратора. is_active — это «текущая выбранная стадия», он гаснет, когда организатор активирует следующую. is_published — липкий: выставляется один раз при активации и больше не сбрасывается. Все действия капитанов и pick/ban проверяют именно is_published, поэтому стадия, которая уже выходила в игру, остаётся рабочей навсегда, а по непубликованному предпросмотру сетки записи отклоняются с 409.

Стадия раскладывается на элементы (stage_item) с типом group, bracket_upper, bracket_lower или single_bracket, а у элемента есть входы (stage_item_input) — по одному на слот. Вход бывает final (команда известна), tentative (место занято результатом другой стадии — есть source_stage_item_id и source_position) или empty. Сколько команд проходит дальше, задаёт advance_count на стадии, с возможностью переопределить его на отдельной группе.

ffa_league

FFA-стадия не сводит команды парами: её элементы — обычные группы, но встреча в такой группе одна на всю группу и имеет slot_count больше 2 вместо двух мест. Это лобби: от 3 до 100 участников, best_of игр, в каждой игре у каждого участника одно место и по одному значению на каждый столбец, который ведёт стадия. teams[] лобби перечисляет все места, а списки встреч по умолчанию отдают только дуэли (slot_count по умолчанию 2) — читайте лобби через его собственные маршруты:

GET /api/v1/tournaments/42/stages/7/ffa HTTP/1.1
Host: owt.craazzzyyfoxx.me

GET /api/v1/tournaments/{id}/stages/{stage_id}/ffa отдаёт все лобби стадии, GET /api/v1/encounters/{encounter_id}/ffa — одно; форма ответа в обоих случаях одна и та же. В ней приходят advance_count (переопределение группы, иначе число стадии; null — линии выхода нет), rules и rows. Каждая строка — участник: team_id, slot, position, tie_group, points, games_played, wins, stats (сумма по каждому столбцу) и массив games по одной ячейке на позицию игры; в ячейке — placement, points и собственный stats. У неоткрытой игры state равен null — это не ноль очков, а игра, которую ещё никто не вводил. Оба маршрута публичные и отдают только ПУБЛИЧНЫЕ столбцы: столбца с public: false нет ни в rules.columns, ни в stats строки, ни в stats ячейки. Организатор читает лобби целиком через GET /api/v1/admin/tournaments/{id}/stages/{stage_id}/ffa — нужно право match.update на воркспейс турнира.

Правила считаются из поля scoring стадии (preset points, рядом с win/draw/loss, которые платит таблица): columns (что игра записывает по каждой команде — key, label, public, better; не больше 10), placement_points (очки за 1-е, 2-е, … место; за концом списка — ноль) и formula — выражение, по которому считаются очки игры. Формула читает ключи столбцов плюс place (место в этой игре), place_pts (placement_points[место − 1], ноль за концом списка) и teams (размер лобби); доступны арифметика, сравнения, and/or/not, min, max, abs, round и if(условие, то, иначе) — и больше ничего. Разбор идёт стандартным модулем ast по белому списку узлов, без eval (backend/shared/domain/ffa_formula.py); отвергнутая формула отвечает 422 с кодом (ffa_formula_syntax, ffa_formula_unknown_name, ffa_formula_unsupported, ffa_formula_too_complex) и позицией в строке. Очки игры — значение этого выражения, округлённое до 4 знаков; деление на ноль даёт ноль, отрицательные очки допустимы (столбец-штраф). Обязательно ли место при вводе игры — не настройка, а факт: читает ли формула place или place_pts. Если не читает, места выводятся из очков игры, равные очки делят место — и выводятся при каждом чтении, а не хранятся: правка формулы сдвигает места вместе с очками. Введённое организатором место хранится и переживает любую последующую правку. Очки нигде не хранятся — запись, таблица и чтение лобби считают их из сырых значений, поэтому правка формулы пересчитывает все игры стадии. Такая правка отвергается с 409, как только любой элемент стадии посеял уже начатую следующую стадию, а удаление или переименование столбца, под ключом которого уже есть значения, — с 422 ffa_column_in_use. Формула, читающая place или place_pts, отвергается с 422 ffa_formula_places_missing, пока в живой игре стадии не введены места: места такой игры выводятся из очков самой формулы, а такая формула их дать не может — сначала введите эти места.

Организатор вводит игру позиции p (POST /api/v1/admin/encounters/{id}/ffa/games/{position}/results), аннулирует её (…/cancel) или меняет число игр лобби (…/ffa/games-count). Лобби завершается само, когда подтверждённых живых игр стало не меньше best_of.

GET /api/v1/tournaments/42/stages HTTP/1.1
Host: owt.craazzzyyfoxx.me

Сетка: встречи и связи

Встреча (encounter) — это серия между посаженными командами: slot_count и teams[] — по записи на каждого живого участника (id, slot, team_id, name, seed, score, placement), а также best_of, result_source, result_version, round, status (open, pending, completed) и result_status (none, pending_confirmation, confirmed, disputed).

Сетка — это не подразумеваемое дерево, а явные рёбра. Каждое ребро говорит: место (source_position: 1 — первое) встречи-источника занимает слот (slot) встречи-цели. Входящие рёбра отдаются на встрече в поле sources, поэтому пустой слот можно подписать («проигравший матча 3»), не угадывая форму сетки по номерам раундов.

Генерация зависит от типа стадии:

  • Круговая — метод кругов, bye пропускается; рёбер продвижения нет вовсе.
  • Одиночное и двойное выбывание — скелет сетки плюс рёбра. В двойном выбывании нижнюю сетку можно дополнительно засеять командами из групп: advance_upper_count у групповой стадии (или у отдельной группы) задаёт, сколько из проходящих стартуют в верхней сетке; остальные стартуют в элементе bracket_lower. Вместо сгенерированной схемы админ может нарисовать свою (Stage.bracket_template): слоты с местами под посевы U#/L#; превью, план раундов и генерация строятся по ней, а редактировать её можно, только пока у стадии нет матчей.
  • Швейцарка — раунд за раундом.

Гранд-финальный сброс в двойном выбывании создаётся лениво. Стадия должна быть настроена как de_grand_final_type = "with_reset", и матч появляется только в момент, когда гранд-финал выигрывает команда, пришедшая из нижней сетки. Если победил представитель верхней сетки, турнир заканчивается на гранд-финале, а заготовка сброса удаляется.

Швейцарская жеребьёвка работает так. Команды сортируются по очкам и Бухгольцу; группы с большим числом очков разбираются первыми; внутри группы предпочитаются пары «верхняя половина против нижней», а соперник из своей группы — предпочтительнее спуска в соседнюю. Повторы пар избегаются, причём генератор заглядывает на раунд вперёд: локально красивая жеребьёвка может загнать поле в состояние, где следующий раунд без повторов уже не собрать. Если поле действительно доигралось до угла, раунд всё равно строится — с минимальным числом повторов, потому что полное поле важнее первой встречи, — и только при полной невозможности собрать раунд возвращается ошибка. При нечётном числе команд bye достаётся самой низкой в таблице команде, у которой его ещё не было.

Пороги швейцарки досрочно завершают её для команды; оба необязательны. Набрав swiss_qualify_wins побед (bye считается победой), команда проходит дальше, набрав swiss_eliminate_losses поражений — выбывает; ничья не считается ни тем, ни другим. Решённую команду больше не ставят в пары. Когда нерешённых команд остаётся меньше двух, следующий раунд не генерируется и группа считается завершённой даже до max_rounds, который остаётся жёстким пределом. Стадия с порогом никогда не разворачивается в круговую систему целиком. Матч, где одной из команд не хватает одной победы до прохода, играется как best_of.swiss_qualify, одного поражения до вылета — как best_of.swiss_eliminate (если и то и другое — берётся больший); оба значения важнее best-of раунда. Выход в следующую стадию по-прежнему берёт первые advance_count мест таблицы.

Продвижение срабатывает, когда встреча стала completed и счёт не ничейный. Оно идемпотентно: если в целевом слоте уже стоит та же команда, ничего не происходит. Ничья не может завершить встречу в сетке на выбывание — такой счёт отклоняется с 400, иначе рёбра не сработают и сетка встанет.

Если результат выше по сетке исправили и победитель сменился, всё, что старый результат успел продвинуть, каскадно сбрасывается: целевая встреча «разыгрывается обратно», и то же самое происходит с её собственными потомками. В журнале результатов такие переходы помечены действием cascade_reset, а инициировавшее действие администратора — reopen.

Таблица

Таблица считается по стадии и отдаётся на GET /api/v1/tournaments/{id}/standings.

Главное правило: учитываются только полностью закрытые раунды. Раунд считается играбельным, если обе команды в нём известны; если хотя бы одна играбельная встреча раунда ещё не завершена, весь раунд в расчёт не идёт. Иначе форма команды убегала бы вперёд собственных побед и поражений.

Очки берутся из турнира — win_points (по умолчанию 1.0), draw_points (0.5), loss_points (0.0) — и могут быть переопределены на стадии полем scoring (win, draw, loss; член со значением null наследует значение турнира). Швейцарский bye по умолчанию оценивается как победа; отдельное значение задаётся полем стадии swiss_bye_points.

В строке таблицы приходят position, overall_position, matches, win, draw, lose, points, buchholz (усечённый, он же медианный), full_buchholz (полная сумма), score_differential, tie_group, is_pinned, swiss_status (qualified / eliminated, когда команда достигла порога швейцарки, иначе null), а также tiebreak_order и source_rule_profile — применённый порядок тай-брейков и профиль, из которого он взят.

Сам порядок тай-брейков задаётся полем стадии tiebreak_order, а пока оно равно null, берётся пресет — ranking_preset стадии или пресет по её типу. Известные метрики: points, match_wins, head_to_head, buchholz, median_buchholz, score_differential, map_differential, wins_as_higher_stage_specific_metric. Неизвестные строки из настроенного порядка отбрасываются, дубликаты схлопываются до первого вхождения.

На FFA-стадии таблица группы считается по её лобби, и метрики другие — у лобби нет соперника, поэтому личные встречи и обе разновидности Бухгольца там не вычисляются вовсе. Доступны ffa_game_wins (число игр, выигранных с 1-го места; общее первое место считается), ffa_best_placement (лучшее место), ffa_last_placement (место в последней сыгранной игре) и по одной метрике на каждый столбец стадии — ffa_stat:<ключ>, сумма этого столбца. Столбец с better: "lower" (смерти, штрафы) сортируется по возрастанию. Метрики мест и суммы столбцов с better: "lower" возвращаются со знаком минус, чтобы сортировка «больше — лучше» оставалась одна на все метрики. Запись ffa_stat: с ключом столбца, которого на стадии больше нет, отбрасывается, как любая неизвестная метрика. Пресет по умолчанию — ffa_default: points, ffa_game_wins, ffa_stat:<первый столбец> (если он есть), ffa_last_placement.

Места, закреплённые организатором, хранятся в tournament.standing_pin: одна строка на закреплённую команду в каждой таблице, где таблица — пара (stage_id, stage_item_id). Закрепления применяются после ранжирования: закреплённая команда занимает своё место, незакреплённые заполняют свободные места в вычисленном порядке. Соседние незакреплённые команды, делившие место в плей-офф, продолжают его делить. У закреплённой строки выставлен is_pinned, и она никогда не входит в tie_group — её место решено, а не осталось неразведённым. PUT /api/v1/admin/stages/{stage_id}/standing-pins с телом { "stage_item_id": …, "pins": [{ "team_id": …, "position": … }] } заменяет все закрепления одной таблицы (пустой список снимает их) и отвечает 202 с задачей пересчёта. Команда не из этой таблицы, место за её концом или дубликат дают 422. Когда плей-офф, посеянный из группы, уже начался, изменение, затрагивающее её места выхода или снимающее закрепление, отклоняется с 409.

Пересчёт таблицы — фоновая задача: POST /api/v1/admin/standings/recalculate/{tournament_id} отвечает 202, статус задачи читается через /api/v1/admin/tournament-jobs. Для швейцарки пересчёт заодно генерирует следующий раунд, когда текущий закрылся.

Что читать и на что подписываться

Публичные чтения — /api/v1/tournaments, /api/v1/tournaments/{id}, /api/v1/tournaments/{id}/stages, /api/v1/tournaments/{id}/standings, /api/v1/encounters, /api/v1/encounters/{id}. Список турниров фильтруется по workspace_id, status и is_league.

Список встреч фильтруется по tournament_id, stage_id, stage_item_id, best_of, discipline, status, has_logs, is_final, is_upset, closeness_min/closeness_max и scope (all или my_team), а ищет по query:

  • status — сохранённый статус (open, pending, completed) или производный: live (идёт сейчас) и upcoming (открыта или ожидает и ещё не идёт). Любое другое значение — 400.
  • is_final=true оставляет последний положительный раунд стадии с одиночным или двойным выбыванием (в двойном — гранд-финал); false его исключает. Каждая встреча несёт и поле is_final.
  • is_upset=true оставляет завершённые дуэли, которые выиграла команда, стоящая ниже (с большим position) в том же элементе стадии; false их исключает.
  • Напряжённость — полуинтервал [closeness_min, closeness_max); при closeness_max, равном 1, он замкнут.
  • query с fields=name ищет по названию встречи (в любом порядке команд), по названиям участвующих команд и по именам игроков в их составах.
  • sort — одно из id, name, best_of, status, scheduled_at, closeness, has_logs, discipline и played_at (иначе 400). NULL в любом направлении идут последними, равенство разрешает id; played_at упорядочивает по дате игры, затем по расписанию, затем по датам турнира.

/api/v1/encounters/overview принимает те же фильтры и отдаёт счётчики: сегменты статуса (all, live, upcoming, completed) не учитывают status, а каждый переключатель (finals, upsets, with_logs, my_team) считает текущие фильтры с ним самим включённым. Ещё в ответе completed_with_logs, recent_completed (сыгранные за последние 7 дней) и до 4 идущих встреч. /api/v1/encounters/insights принимает их же, но status всегда completed, и отдаёт аналитику: гистограмму напряжённости по десятым, счета, виды стадий, самые частые карты, самые напряжённые встречи, среднюю длительность встречи и сухие победы. Доли — дроби от 0 до 1.

Полный перечень маршрутов и схемы ответов — в справочнике эндпоинтов.

Административная часть живёт под /api/v1/admin/: статус и расписание турнира, CRUD стадий, элементов и входов, activate-and-generate для генерации сетки (202, задача), пересчёт таблицы.

Изменения приходят не отдельными событиями на каждый объект, а именами протухших ресурсов на топике tournament:{id}:invalidation. Для этой статьи существенны tournament.detail, tournament.stages, tournament.encounters, tournament.standings, tournament.teams и tournament.structure — последний означает, что изменился сам набор разделов страницы турнира. Механика подписки и доигрывания описана в статье Realtime (WebSocket).

{ "resources": ["tournament.encounters", "tournament.standings"] }

Скрытый турнир отдаёт 404 и на HTTP, и в проверке доступа к топикам: подписка на его топики анонимному клиенту не выдаётся.

См. также