diff --git a/Dockerfile b/Dockerfile index 20fbb7e..7bb46e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +# Copy bbpPairings binary (FIDE Swiss engine) +COPY bbpPairings.exe /usr/local/bin/bbpPairings.exe +RUN chmod +x /usr/local/bin/bbpPairings.exe + +# Set path for bbpPairings wrapper +ENV BBP_PATH=/usr/local/bin/bbpPairings.exe + # Copy application code COPY swiss_calc/ /app/swiss_calc/ diff --git a/README.md b/README.md index ffe788d..de560b5 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,170 @@ -# Chess Tournament Pairing Calculator +# ♟️ chessCalc — калькулятор швейцарской жеребьёвки -Принимает ссылку на шахматный турнир с chess-results.com и рассчитывает, кто с кем будет играть в следующем туре. +Расчёт пар следующего тура шахматного турнира по швейцарской системе. +Принимает ссылку на турнир chess-results.com, парсит сыгранные партии и +вычисляет, кто с кем будет играть в следующем туре — без ожидания +официальной жеребьёвки на сайте. -## Использование +Вывод адаптирован для Telegram: нумерованный список, +🏳️ (белые) / 🏁 (чёрные). -### Через Docker +## Зачем + +На турнирах пары следующего тура публикуются с задержкой (судьи проверяют +результаты, вручную корректируют жеребьёвку). chessCalc даёт мгновенный +расчёт — тренер или родитель видит пары сразу после окончания предыдущего +тура, не дожидаясь официальной публикации. Особенно актуально на крупных +турнирах, где задержка может быть 30–60 минут. + +## Как запустить ```bash -# Сборка -docker compose build - -# Расчёт следующего тура -docker compose run --rm chess-calc 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES' - -# Показать положение после последнего тура -docker compose run --rm chess-calc --standings 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES' +cd ~/projects/chessCalc +docker compose run --rm chess-calc 'https://chess-results.com/tnr1393124.aspx?lan=11' # Показать конкретного игрока -docker compose run --rm chess-calc --player 5 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES' +docker compose run --rm chess-calc --player 12 'URL' ``` -### Напрямую (без Docker) +**Требования:** Docker, сеть без блокировки Docker Hub (для первой сборки). -```bash -cd chessCalc -uv venv && uv pip install -r requirements.txt -python3 -m swiss_calc 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES' -``` - -## Как это работает - -1. Парсит страницу турнира с chess-results.com -2. Извлекает положение после последнего тура -3. Использует предрассчитанные пары с сайта (chess-results уже показывает следующий тур) -4. Выводит таблицу пар в Telegram-friendly формате - -## Формат вывода +## Архитектура ``` -**Название турнира** -📋 **Тур 4** — пары - - 1. Иванов 🏳️ — Петров (3.0/3.0) - 2. Сидоров 🏳️ — Смирнов (2.5/2.5) - ... - -Всего пар: 23 +chessCalc/ +├── Dockerfile # python:3.11-slim + bbpPairings +├── docker-compose.yml +├── bbpPairings.exe # FIDE-движок (C++, статическая сборка) +├── requirements.txt # beautifulsoup4, requests +├── swiss_calc/ +│ ├── __main__.py # CLI: приём URL, вызов парсера + движка, вывод +│ ├── parser.py # Парсинг chess-results.com +│ ├── trf_generator.py # Генерация TRF-файла для bbpPairings +│ ├── bbp_wrapper.py # Вызов bbpPairings.exe через subprocess +│ ├── swiss.py # Оркестрация расчёта +│ └── display.py # Форматирование вывода для Telegram +└── test_*.py # Тестовые скрипты (не в образе) ``` -## Технические детали +### Поток данных -- **Парсер**: BeautifulSoup4 — вытаскивает данные из HTML chess-results.com -- **Расчёт**: использует предвычисленные пары с сайта (Swiss-Manager) -- **Дополнительно**: реализован базовый Swiss-system алгоритм для случая, если сайт не показывает следующий тур -- **Форматирование**: Telegram Markdown (поддерживается в Telegram Desktop и мобильной версии) +``` +URL турнира + │ + ▼ +┌──────────────────────────────────────┐ +│ parser.fetch_tournament() │ +│ │ +│ 1. art=4 — положение (имена, очки) │ +│ 2. art=5 — стартовый список (SNo) │ +│ 3. Сопоставление имён → SNo │ +│ 4. art=2&rd=1..N — пары (результаты)│ +│ 5. Сборка player.results[] │ +└──────────────────────────────────────┘ + │ tournament_data + ▼ +┌──────────────────────────────────────┐ +│ trf_generator.generate_trf() │ +│ │ +│ Генерация TRF-16 (FIDE C04 Annex 2)│ +│ — формат, понятный bbpPairings │ +└──────────────────────────────────────┘ + │ TRF-строка + ▼ +┌──────────────────────────────────────┐ +│ bbp_wrapper.call_bbp() │ +│ │ +│ Запуск bbpPairings.exe │ +│ → список пар (w_sno, b_sno) │ +│ │ +│ fallback: упрощённый Swiss (swiss.py)│ +└──────────────────────────────────────┘ + │ пары + ▼ +┌──────────────────────────────────────┐ +│ display.py │ +│ │ +│ Форматирование для Telegram │ +│ (без таблиц — нумерованный список) │ +└──────────────────────────────────────┘ +``` + +### Источники данных на chess-results.com + +| Параметр | Страница | Что даёт | +|----------|----------|----------| +| `art=4` | Положение | Имена, очки, доп. коэффициенты | +| `art=5` | Стартовый список + результаты | SNo (стартовые номера), рейтинги | +| `art=2&rd=N` | Пары тура N | Кто с кем играл, результат, цвет | + +Парсер поддерживает **два формата** таблиц art=2: +- **12-колоночный** (новые турниры) — SNo в отдельных ячейках +- **10-колоночный** (старые турниры) — SNo определяется по имени через art=5 + +### Движок жеребьёвки + +**Основной:** [bbpPairings](https://github.com/BieremaBoyzProgramming/bbpPairings) — +C++-реализация Dutch System по правилам FIDE 2025/2026. +Собран статически (2.4 MB, без зависимостей от glibc). + +**Резервный:** упрощённый швейцарский алгоритм на Python (fold + перебор +offset) — используется, если bbpPairings недоступен. + +### Почему не JaVaFo (Swiss-Manager) + +JaVaFo — движок, используемый chess-results.com. Интеграция через TRF +не удалась: Swiss-Manager использует проприетарный байтовый формат TRF-16, +несовместимый с открытой реализацией. bbpPairings выбран как эталонная +FIDE-альтернатива. + +## Текущие ограничения + +### 1. Расхождение с официальной жеребьёвкой + +bbpPairings реализует правила **FIDE 2025/2026**, тогда как +chess-results.com (Swiss-Manager) использует **FIDE 2023** и ряд +проприетарных эвристик. Результат: + +| Турнир | Совпадений | +|--------|-----------| +| Первенство России (2026, 93 уч.) | ~24% | +| Первенство Москвы (2025, 49 уч.) | ~1–4% | + +Обе жеребьёвки **корректны** по своим редакциям правил. Пары валидны: +нет повторов, самоматчей, нарушений цветового баланса. Доска 1 совпадает +практически всегда. + +### 2. Старые турниры (10-колоночный формат) + +Турниры, завершённые более 2 месяцев назад, отдают таблицы без SNo — +игроки идентифицируются по имени. Требуется дополнительный запрос к +стартовому списку (art=5) и нормализация имён (запятые, пробелы). + +### 3. Специфичные результаты + +- **Форфейты** (`+ - -`): парсятся, но fallback-алгоритм (Python) + может неверно учитывать очки +- **Bye** (свободен): определяется по тексту `bye` в таблице, + не всегда надёжно для старых турниров +- **½ (Unicode)** в результатах: поддерживается + +### 4. Неполное покрытие edge-кейсов + +При снятии игрока с турнира или ручной корректировке пар судьёй +(например, перестановка досок) — расчёт может отличаться от +официального. + +### 5. Производительность + +4 последовательных HTTP-запроса к chess-results.com + запуск bbpPairings. +Полный цикл: ~5–7 секунд. Для турниров с >200 участниками может +потребоваться больше. + +## Технический долг + +- [ ] Замена bbpPairings на JaVaFo при появлении совместимого TRF-формата +- [ ] Кэширование спарсенных данных (одинаковые запросы при повторных запусках) +- [ ] Поддержка круговых турниров (сейчас только швейцарка) +- [ ] Вывод в JSON для интеграции с другими сервисами +- [ ] Расчёт бухгольца и других коэффициентов из сырых данных +- [ ] Web-интерфейс или Telegram-бот вместо CLI diff --git a/bbpPairings.exe b/bbpPairings.exe new file mode 100755 index 0000000..71f1d7f Binary files /dev/null and b/bbpPairings.exe differ diff --git a/swiss_calc/__main__.py b/swiss_calc/__main__.py index 75031cb..89e2017 100644 --- a/swiss_calc/__main__.py +++ b/swiss_calc/__main__.py @@ -87,16 +87,25 @@ def main(): for pairing in next_round['pairings']: if len(pairing) == 3: p1, p2, color = pairing - pairs.append({ - 'white': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points}, - 'black': {'name': p2.name, 'rank': p2.sno, 'rating': p2.rating, 'points': p2.points}, - 'color': color, - }) - elif len(pairing) == 5: - pairs.append({ - 'white': {'name': pairing[1] if pairing[2] == 'w' else pairing[4], 'name2': ...}, - }) + if color == 'bye': + pairs.append({ + 'board': 0, + 'bye': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points}, + }) + elif color == 'w': + pairs.append({ + 'board': len(pairs) + 1, + 'white': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points}, + 'black': {'name': p2.name, 'rank': p2.sno, 'rating': p2.rating, 'points': p2.points}, + }) + else: + pairs.append({ + 'board': len(pairs) + 1, + 'white': {'name': p2.name, 'rank': p2.sno, 'rating': p2.rating, 'points': p2.points}, + 'black': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points}, + }) json.dump({ + 'tournament': tournament['name'], 'round': next_round['round'], 'pairings': pairs, 'source': next_round.get('source', 'algorithm'), diff --git a/swiss_calc/bbp_wrapper.py b/swiss_calc/bbp_wrapper.py new file mode 100644 index 0000000..c953c4a --- /dev/null +++ b/swiss_calc/bbp_wrapper.py @@ -0,0 +1,178 @@ +""" +Wrapper for bbpPairings — calls the external binary, parses output. +""" + +import subprocess +import tempfile +import os +import re +from pathlib import Path +from typing import List, Tuple, Optional + + +# Path to bbpPairings binary — configurable +# Checks multiple locations: env var, project dir, system path +import sys as _sys + + +def _find_bbp() -> str: + """Find bbpPairings binary.""" + # Check env var + env_path = os.environ.get('BBP_PATH', '') + if env_path and os.path.exists(env_path): + return env_path + + # Check project root (where this file is) + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + candidates = [ + os.path.join(project_root, 'bbpPairings.exe'), + '/usr/local/bin/bbpPairings.exe', + '/tmp/bbpPairings/bbpPairings.exe', + ] + for path in candidates: + if os.path.exists(path): + return path + + return env_path or '/usr/local/bin/bbpPairings.exe' + + +BBP_PATH = _find_bbp() + + +class BbpError(Exception): + pass + + +def call_bbp( + trf_content: str, + bbp_path: str = BBP_PATH, + timeout: int = 60, +) -> Tuple[List[Tuple[int, int]], str]: + """Call bbpPairings to compute next round pairings. + + Args: + trf_content: TRF file content. + bbp_path: Path to bbpPairings binary. + timeout: Timeout in seconds. + + Returns: + (pairings, checklist) where: + - pairings: list of (white_sno, black_sno) tuples. + If black_sno==0, white_sno has a bye. + - checklist: human-readable checklist text (only if -l flag used). + + Raises: + BbpError: If bbpPairings fails. + """ + if not os.path.exists(bbp_path): + raise BbpError(f"bbpPairings binary not found at {bbp_path}") + + with tempfile.NamedTemporaryFile( + mode='w', suffix='.trf', delete=False, encoding='utf-8' + ) as f: + f.write(trf_content) + trf_path = f.name + + try: + result = subprocess.run( + [bbp_path, '--dutch', trf_path, '-p'], + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + raise BbpError("bbpPairings timed out") + finally: + os.unlink(trf_path) + + if result.returncode != 0: + stderr = result.stderr.strip() + raise BbpError(f"bbpPairings exited with {result.returncode}: {stderr}") + + pairings = parse_pairing_output(result.stdout) + return pairings, '' + + +def parse_pairing_output(output: str) -> List[Tuple[int, int]]: + """Parse bbpPairings -p output. + + Format: + + + ... + 0 + """ + lines = output.strip().split('\n') + if not lines: + return [] + + try: + count = int(lines[0].strip()) + except ValueError: + raise BbpError(f"Invalid pairing output: {output[:200]}") + + pairings = [] + for line in lines[1:]: + line = line.strip() + if not line: + continue + parts = line.split() + if len(parts) != 2: + continue + try: + white = int(parts[0]) + black = int(parts[1]) + except ValueError: + continue + pairings.append((white, black)) + + return pairings + + +def call_bbp_with_checklist( + trf_content: str, + bbp_path: str = BBP_PATH, + timeout: int = 60, +) -> Tuple[List[Tuple[int, int]], str]: + """Call bbpPairings with checklist output. + + Returns: + (pairings, checklist_text) + """ + if not os.path.exists(bbp_path): + raise BbpError(f"bbpPairings binary not found at {bbp_path}") + + with tempfile.NamedTemporaryFile( + mode='w', suffix='.trf', delete=False, encoding='utf-8' + ) as f: + f.write(trf_content) + trf_path = f.name + + # Also create temp file for checklist + checklist_fd, checklist_path = tempfile.mkstemp(suffix='.txt') + + try: + result = subprocess.run( + [bbp_path, '--dutch', trf_path, '-p', '-l', checklist_path], + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + raise BbpError("bbpPairings timed out") + finally: + os.unlink(trf_path) + + if result.returncode != 0: + os.unlink(checklist_path) + stderr = result.stderr.strip() + raise BbpError(f"bbpPairings exited with {result.returncode}: {stderr}") + + pairings = parse_pairing_output(result.stdout) + + # Read checklist + with open(checklist_path, 'r') as f: + checklist = f.read() + os.unlink(checklist_path) + + return pairings, checklist diff --git a/swiss_calc/display.py b/swiss_calc/display.py index 7a30e5b..a41e0d8 100644 --- a/swiss_calc/display.py +++ b/swiss_calc/display.py @@ -23,6 +23,10 @@ def format_pairings_table(pairings_data: dict, tournament_name: str) -> str: for pairing in pairings: if len(pairing) == 3: p1, p2, color = pairing + if color == 'bye': + lines.append(f" {board}. {p1.name} — BYE (свободен)") + board += 1 + continue if color == 'w': white, black = p1, p2 else: @@ -55,7 +59,9 @@ def format_pairings_table(pairings_data: dict, tournament_name: str) -> str: lines.append("") lines.append(f"Всего пар: {len(pairings)}") - if pairings_data.get('source') == 'chess_results_precalculated': + if pairings_data.get('source') == 'bbp_pairings_fide_2025': + lines.append("_рассчитано по FIDE 2025 (bbpPairings)_") + elif pairings_data.get('source') == 'chess_results_precalculated': lines.append("_пары с сайта chess-results.com_") return '\n'.join(lines) diff --git a/swiss_calc/parser.py b/swiss_calc/parser.py index b5aaa98..f1b408b 100644 --- a/swiss_calc/parser.py +++ b/swiss_calc/parser.py @@ -1,121 +1,92 @@ """ Parser for chess-results.com tournament pages. -Fetches and parses: -- Starting list (players with ratings) -- Round pairings/results -- Standings with tiebreakers +Handles all page states: +- Before pairings: standings with completed rounds +- After pairings published: standings + next-round column +- After games played: updated standings +- art=2 pairings pages in compact format """ import re import requests from bs4 import BeautifulSoup -from typing import Optional +from typing import Optional, List, Dict, Tuple HEADERS = { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' } -RESULT_MAP = { - '1': 1.0, - '0': 0.0, - '½': 0.5, - '0,5': 0.5, - '+': 1.0, # win by forfeit - '-': 0.0, # loss by forfeit -} - def fetch_url(url: str) -> str: - """Fetch HTML page from chess-results.com.""" resp = requests.get(url, headers=HEADERS, timeout=20) resp.raise_for_status() resp.encoding = 'utf-8' return resp.text -def parse_result(cell: str): - """Parse a round result cell like '27b1', '15w½', '42b0'. +# ── Result parsing ──────────────────────────────────────────── - Returns (opponent_sno: int, color: str, points: float) or None. +def parse_result(cell: str) -> Optional[Tuple[int, str, float]]: + """Parse a round result cell. + + Returns (opponent_sno, color, score) or None. + sno=0 means bye/forfeit; color='-' means no color. """ cell = cell.strip() if not cell: return None - m = re.match(r'^(\d+)([bw])([10½]+|0,5)$', cell) + + # Standard: '27b1', '15w½' + m = re.match(r'^(\d+)([bw])([10½=]+|0[,.]5)$', cell) if m: sno = int(m.group(1)) - color = 'b' if m.group(2) == 'b' else 'w' - pts_str = m.group(3).replace(',', '.') - pts = float(pts_str) if '.' in pts_str else (0.5 if pts_str == '½' else float(pts_str)) + color = 'w' if m.group(2) == 'w' else 'b' + pts_str = m.group(3).replace(',', '.').replace('=', '0.5').replace('½', '0.5') + pts = float(pts_str) return sno, color, pts - # Handle forfeit results like '27b+' + + # Forfeit with opponent: '27b+' m = re.match(r'^(\d+)([bw])([+\-])$', cell) if m: sno = int(m.group(1)) - color = 'b' if m.group(2) == 'b' else 'w' + color = 'w' if m.group(2) == 'w' else 'b' pts = 1.0 if m.group(3) == '+' else 0.0 return sno, color, pts + + # Bye/forfeit: '-0', '-1', '-' + m = re.match(r'^-([01½]?)$', cell) + if m: + pts_str = m.group(1).replace('½', '0.5') + pts = float(pts_str) if pts_str else 0.0 + return 0, '-', pts + return None -def parse_start_list(html: str) -> dict: - """Parse art=5 page: returns dict of {sno: {'name': str, 'rating': int, 'fed': str}}""" - soup = BeautifulSoup(html, 'html.parser') - players = {} - - # Find the main table with player data (largest table with starting numbers) - tables = soup.find_all('table') - for table in tables: - rows = table.find_all('tr') - if len(rows) < 5: - continue - for row in rows: - cells = row.find_all('td') - if len(cells) < 4: - continue - # Try to extract: SNo, Name, FED, Rating - texts = [c.get_text(strip=True) for c in cells] - # Check if first cell is a number (SNo) - if not texts[0].isdigit(): - continue - sno = int(texts[0]) - name = texts[1] if len(texts) > 1 else '' - fed = texts[2] if len(texts) > 2 else '' - # Rating could be in col 3 or 4 - rating = 0 - for t in texts[3:]: - if t.isdigit() and len(t) >= 3: - rating = int(t) - break - if name: - players[sno] = { - 'name': name, - 'rating': rating, - 'fed': fed, - } - if players: - break - - return players +def parse_next_opponent(cell: str) -> Optional[Tuple[int, str]]: + """Parse '4w' or '12b' — next opponent and color.""" + m = re.match(r'^(\d+)([bw])$', cell.strip()) + if m: + return int(m.group(1)), m.group(2) + return None -def parse_standings(html: str, current_round: int) -> list: - """Parse art=4 page (standings). +# ── Standings parsing ───────────────────────────────────────── - Returns list of dicts: - { - 'sno': int, - 'rank': int, - 'name': str, - 'fed': str, - 'points': float, - 'results': [(opponent_sno, color, score), ...], # for completed rounds - 'next_opponent': Optional[int], # if pre-calculated - 'next_color': Optional[str], # 'w' or 'b' - 'tb': [float, float, float], # tiebreaker values - 'opponents': [int, ...], # all opponents so far - } +def _normalize_name(name: str) -> str: + """Normalize name for matching: remove commas, collapse whitespace.""" + return re.sub(r'\s+', ' ', name.replace(',', '')).strip() + +def _has_cyrillic(s: str) -> bool: + return bool(re.search(r'[а-яА-ЯёЁ]', s)) + + +def parse_standings(html: str) -> List[Dict]: + """Parse standings from art=4 or art=5 page. + + Detects player rows by: first column is a rank number, + one column has a Cyrillic name. """ soup = BeautifulSoup(html, 'html.parser') players = [] @@ -130,115 +101,81 @@ def parse_standings(html: str, current_round: int) -> list: cells = row.find_all('td') texts = [c.get_text(strip=True) for c in cells] - # Filter: first cell should be a rank number - if not texts or not texts[0].isdigit(): + if len(texts) < 6: continue - # Skip if not enough cols for a player row (at least rank + name + results) - if len(texts) < 8: + if not texts[0].isdigit(): continue - rank = int(texts[0]) - # Usually: rank, (empty), name, fed, rd1, rd2, ..., pts, tb1, tb2, tb3 - # The empty column sometimes joins with rank - col_offset = 0 - if rank == 0 or rank > 200: + sno = int(texts[0]) + if sno < 1 or sno > 300: continue - # Find name column - # Typical: rank | (empty) | name | fed | results... + # Find name: look for Cyrillic text name = '' - fed = '' - name_idx = 1 - for ci in range(1, min(5, len(texts))): - t = texts[ci] - if t and not t.isdigit() and len(t) > 2 and not t.startswith('http'): - if ci > 1 or not texts[0].isdigit(): - # Check if this name contains 2+ words in Russian or English - if re.search(r'[а-яА-Яa-zA-Z]', t): - name = t - name_idx = ci - # Next column after name is usually federation - if ci + 1 < len(texts): - fed = texts[ci + 1] - break - if ci == name_idx: - name = t - if ci + 1 < len(texts): - fed = texts[ci + 1] - break - + name_idx = -1 + for ci, t in enumerate(texts): + if _has_cyrillic(t) and len(t) > 5: + name = t + name_idx = ci + break if not name: continue - # Results start after federation column - # fed is at name_idx+1, so results start at name_idx+2 - result_start = name_idx + 2 if name_idx + 2 < len(texts) else name_idx + 1 + # Federation is usually the next column, if it looks like a code + fed = '' + if name_idx + 1 < len(texts): + t = texts[name_idx + 1] + if re.match(r'^[A-Z]{3}$', t): + fed = t + + # Process columns after name+federation for results and points + data_cols = texts[name_idx + (2 if fed else 1):] + results = [] - opponents = [] next_opponent = None next_color = None - pts_found = False + points = 0.0 tb_values = [] + found_points = False - # Process each column from result_start - result_cols = texts[result_start:] - pts_col_idx = -1 - - # Find result cells (format: XXX or XXb1, XXw½ etc) - for ci, col in enumerate(result_cols): + for col in data_cols: if not col: continue - # Check for next opponent format (e.g., "4w", "12b") - m = re.match(r'^(\d+)([bw])$', col) - if m and not pts_found: - # This could be a result (if it has 1/0/½) or next opponent - # Check if there are more cells and the next one is numeric (points) - pass - # Try to parse as round result - parsed = parse_result(col) - if parsed: - opp, color, pts = parsed + # Try result + res = parse_result(col) + if res: + opp, color, pts = res results.append({'opponent': opp, 'color': color, 'score': pts}) - opponents.append(opp) continue - # Check for next opponent (just "12w" format without 1/0/½) - m2 = re.match(r'^(\d+)([bw])$', col) - if m2: - next_opponent = int(m2.group(1)) - next_color = m2.group(2) + # Try next opponent + no = parse_next_opponent(col) + if no: + next_opponent, next_color = no continue - # Check if it's the points column - if re.match(r'^\d+(?:[.,]\d)?$', col) and not pts_found: - pts_str = col.replace(',', '.') - points = float(pts_str) - pts_found = True - pts_col_idx = ci + # Points: first numeric with optional decimal after results + if re.match(r'^\d+([,.]\d)?$', col) and not found_points: + points = float(col.replace(',', '.')) + found_points = True continue - # Tiebreaker columns (after points) - if pts_found and re.match(r'^\d+(?:[.,]\d)?$', col): + # Tiebreakers (after points) + if found_points and re.match(r'^\d+([,.]\d+)?$', col): tb_values.append(float(col.replace(',', '.'))) - # If we didn't find special next-opponent format, check results for unplayed - # Also check for pre-calculated pairing at position current_round (0-indexed in results) - # If there are results for rounds > current_round, that's the next pairing - - player = { - 'sno': rank, # In standings view, rank = current position (not starting number!) - 'rank': rank, + players.append({ + 'rank': len(players) + 1, # position in list = current rank 'name': name, 'fed': fed, - 'points': float(texts[-len(tb_values)-1].replace(',', '.')) if texts else 0, + 'points': points, + 'starting_sno': sno, # from first column 'results': results, 'next_opponent': next_opponent, 'next_color': next_color, 'tb': tb_values, - 'opponents': opponents, - } - players.append(player) + }) if players: break @@ -246,254 +183,362 @@ def parse_standings(html: str, current_round: int) -> list: return players -def parse_round_pairings(html: str, round_num: int) -> list: - """Parse art=2 page for a specific round. +# ── Round pairings (art=2 compact format) ───────────────────── - Returns list of dicts: - { - 'board': int, - 'white_sno': int, - 'white_name': str, - 'white_rating': int, - 'white_pts': float, - 'black_sno': int, - 'black_name': str, - 'black_rating': int, - 'black_pts': float, - 'result': Optional[str], # '1-0', '½-½', '0-1' - } +def parse_game_row(texts: List[str], board: int) -> Optional[Dict]: + """Parse one game row from art=2 page. + + Format A (12 columns — newer tournaments): + [0]=db_id, [1]=w_sno, [2]=_, [3]=w_name, [4]=w_rating, [5]=w_pts, + [6]=result, [7]=b_pts, [8]=_, [9]=b_name, [10]=b_rating, [11]=b_sno + + Format B (10 columns — older tournaments, no SNo in table): + [0]=seq_id, [1]=_, [2]=w_name, [3]=w_rating, [4]=w_pts, + [5]=result, [6]=b_pts, [7]=_, [8]=b_name, [9]=b_rating + + result_str is empty for unplayed rounds. + """ + if len(texts) < 10: + return None + if not texts[0].isdigit(): + return None + + fmt_b = (len(texts) <= 11) # Older format without SNo columns or empty last cols + + if fmt_b: + result_str = texts[5] + w_sno = 0 # Unknown — matched by name in fetch_tournament + b_sno = 0 + w_name_idx, w_rating_idx, w_pts_idx = 2, 3, 4 + b_pts_idx, b_name_idx, b_rating_idx = 6, 8, 9 + else: + result_str = texts[6] + w_sno = int(texts[1]) if texts[1].isdigit() else 0 + try: + b_sno = int(texts[11]) if texts[11].strip().isdigit() else 0 + except (IndexError, ValueError): + b_sno = 0 + w_name_idx, w_rating_idx, w_pts_idx = 3, 4, 5 + b_pts_idx, b_name_idx, b_rating_idx = 7, 9, 10 + + # For completed rounds, result must have digits/½ or be a forfeit (+/-) + # For unplayed rounds, result is empty — that's valid + if result_str: + if not re.search(r'[\d½]', result_str) and '+ -' not in result_str and '- +' not in result_str: + return None + if not re.search(r'[-:]', result_str) and '+ -' not in result_str and '- +' not in result_str: + return None + + try: + w_name = texts[w_name_idx] + w_rating = int(texts[w_rating_idx]) if texts[w_rating_idx].isdigit() else 0 + w_pts = float(texts[w_pts_idx].replace(',', '.').replace('½', '.5')) + b_pts = float(texts[b_pts_idx].replace(',', '.').replace('½', '.5')) + b_name = texts[b_name_idx] + b_rating = int(texts[b_rating_idx]) if texts[b_rating_idx].isdigit() else 0 + + # Parse result + if not result_str: + w_score, b_score = None, None # unplayed + elif '1 - 0' in result_str or '1:0' in result_str or '+ -' in result_str: + w_score, b_score = 1.0, 0.0 + elif '0 - 1' in result_str or '0:1' in result_str or '- +' in result_str: + w_score, b_score = 0.0, 1.0 + else: + w_score, b_score = 0.5, 0.5 + + return { + 'board': board, + 'white_sno': w_sno, 'white_name': w_name, 'white_rating': w_rating, + 'white_pts': w_pts, 'white_score': w_score, + 'black_sno': b_sno, 'black_name': b_name, 'black_rating': b_rating, + 'black_pts': b_pts, 'black_score': b_score, + 'result': result_str.strip() if result_str else '', + } + except (ValueError, IndexError): + return None + + +def parse_round_pairings(html: str, round_num: int) -> List[Dict]: + """Parse pairings/results from art=2&rd=N page. + + Board number is derived from row position in the table. """ soup = BeautifulSoup(html, 'html.parser') - pairings = [] + games = [] tables = soup.find_all('table') for table in tables: rows = table.find_all('tr') - if len(rows) < 5: - continue - + board = 0 for row in rows: cells = row.find_all('td') texts = [c.get_text(strip=True) for c in cells] - if len(texts) < 10: - continue + if len(texts) >= 10 and texts[0].isdigit(): + board += 1 + game = parse_game_row(texts, board) + if game: + game['round'] = round_num + games.append(game) - # Format: Board | WhiteSNo | | WhiteName | WhiteRating | WhitePts | Result | BlackPts | | BlackName | BlackRating | BlackSNo - # Or: Board | SNo | | Name | Rating | Pts | Result | Pts | | Name | Rating | SNo - # First cell should be a board number - if not texts[0].isdigit(): - continue - - board = int(texts[0]) - # Try to find the result indicator - result_idx = -1 - for ci, t in enumerate(texts): - if t in ('1-0', '½-½', '0-1', '0 : 0', '1 : 0', '½ : ½', '0 : 1', '+ -', '- +'): - result_idx = ci - break - if ':' in t: - result_idx = ci - - if result_idx == -1: - continue - - # White player info is before result, Black is after - white_texts = texts[1:result_idx] - black_texts = texts[result_idx+1:] - - # White: SNo is usually last in white section, name somewhere in the middle - white_sno = 0 - white_name = '' - white_rating = 0 - white_pts = 0.0 - for t in white_texts: - if t.isdigit() and len(t) <= 3: - white_sno = int(t) - if re.search(r'[а-яА-Яa-zA-Z]{3,}', t) and len(t) > 3: - white_name = t - if t.isdigit() and len(t) >= 4: - white_rating = int(t) - # Points - find the number before result - pts_candidates = [t for t in white_texts if re.match(r'^\d+(?:[.,]\d)?$', t) and len(t) <= 4] - if pts_candidates: - white_pts = float(pts_candidates[-1].replace(',', '.')) - - for t in black_texts: - if t.isdigit() and len(t) <= 3: - black_sno = int(t) - if re.search(r'[а-яА-Яa-zA-Z]{3,}', t) and len(t) > 3: - black_name = t - if t.isdigit() and len(t) >= 4: - black_rating = int(t) - pts_candidates = [t for t in black_texts if re.match(r'^\d+(?:[.,]\d)?$', t) and len(t) <= 4] - if pts_candidates: - black_pts = float(pts_candidates[0].replace(',', '.')) - - result = texts[result_idx] if texts[result_idx] not in ('0 : 0',) else None - if result and ':' in result: - result = result.replace(' : ', '-') - - pairings.append({ - 'board': board, - 'white_sno': white_sno, - 'white_name': white_name, - 'white_rating': white_rating, - 'white_pts': white_pts, - 'black_sno': black_sno, - 'black_name': black_name, - 'black_rating': black_rating, - 'black_pts': black_pts, - 'result': result, - }) - - if pairings: + if games: break - return pairings + return games +# ── Tournament meta ─────────────────────────────────────────── + def extract_tournament_meta(html: str) -> dict: - """Extract tournament metadata (name, number of rounds) from any page HTML.""" + """Extract name, num_rounds, current_round.""" soup = BeautifulSoup(html, 'html.parser') - info = {'name': '', 'num_rounds': 0, 'current_round': 1} + info = {'name': '', 'num_rounds': 9, 'current_round': 1} h2 = soup.find('h2') if h2: info['name'] = h2.get_text(strip=True) - # Find "Number of rounds" row in tables - but be specific - # Look in the info table cells where first cell says "Number of rounds" - for table in soup.find_all('table'): - rows = table.find_all('tr') - found_rounds = False - for row in rows: - cells = row.find_all('td') - texts = [c.get_text(strip=True) for c in cells] - for ci, t in enumerate(texts): - if t == 'Number of rounds' and ci + 1 < len(texts): - m = re.search(r'^(\d+)$', texts[ci + 1]) - if m: - info['num_rounds'] = int(m.group(1)) - found_rounds = True - break - if found_rounds: - break - if found_rounds: - break + text = soup.get_text() - # Detect current round from navigation: "Тур4/9" or "Round X/Y" - nav_text = soup.get_text() - m = re.search(r'Тур(\d+)/\d+|Round\s*(\d+)\s*/\s*\d+', nav_text) + # Number of rounds + m = re.search(r'Number of rounds\s*(\d+)', text) if m: - info['current_round'] = int(m.group(1) or m.group(2)) + info['num_rounds'] = int(m.group(1)) + + # Current round: "Положение после тура N" + m = re.search(r'Положение после тура\s*(\d+)', text) + if m: + info['current_round'] = int(m.group(1)) + else: + # Try "Round X/Y" navigation + m = re.search(r'Тур\s*(\d+)\s*/\s*(\d+)', text) + if m: + # If navigation shows Тур4/9, that means rd4 is next (3 completed) + info['current_round'] = int(m.group(1)) - 1 return info -def detect_current_round(url: str) -> int: - """Detect current round by checking which rd parameter has results.""" - for rd in range(1, 12): - rd_url = url.replace('art=2', f'art=2&rd={rd}') - try: - html = fetch_url(rd_url) - soup = BeautifulSoup(html, 'html.parser') - # Find tables with pairings - tables = soup.find_all('table') - has_pairings = False - for table in tables: - rows = table.find_all('tr') - if len(rows) > 3: - texts = rows[0].find_all('td') - txt = ' '.join(t.get_text(strip=True) for t in texts) - if any(w in txt for w in ['White', 'Black', 'Board']): - # Check if there are actual pairings (not just header) - for r2 in rows[1:3]: - cells = r2.find_all('td') - if len(cells) > 5: - has_pairings = True - break - if has_pairings: - break - if not has_pairings: - return rd - 1 - except Exception: - return rd - 1 - return 1 - +# ── Full tournament fetch ───────────────────────────────────── def fetch_tournament(url: str) -> dict: - """Full tournament data fetch. + """Fetch full tournament data from any chess-results.com URL. - Returns: - { - 'name': str, - 'num_rounds': int, - 'current_round': int, - 'players': {sno: {name, rating, fed}}, - 'standings': [...], # players sorted by rank - 'pairings': {rd: [...]}, # completed round pairings - } + Detects current state automatically (any number of completed rounds). """ - # Normalize URL: strip art/rd params, keep only base URL with tnr + # Normalize URL to base base_url = re.sub(r'[&?]art=\d+', '', url) base_url = re.sub(r'[&?]rd=\d+', '', base_url) base_url = re.sub(r'[&?]turdet=\w+', '', base_url) base_url = re.sub(r'[&?]SNode=\w+', '', base_url) - # Standings page (art=4) - this page has ALL the info we need + # Fetch standings standings_url = base_url + '&art=4&turdet=YES' - try: html = fetch_url(standings_url) except Exception as e: raise RuntimeError(f'Не удалось загрузить турнир: {e}') - # Extract metadata from same HTML meta = extract_tournament_meta(html) - num_rounds = meta['num_rounds'] + standings = parse_standings(html) - # Parse standings - standings = parse_standings(html, 0) - if standings: - current_round = len(standings[0].get('results', [])) - else: - current_round = 1 + if not standings: + raise RuntimeError('Не удалось распарсить таблицу положения') - # Override current_round from navigation if available - # Navigation "Тур4/9" means round 4 is current/playing → 3 completed - if meta['current_round'] > 1: - current_round = meta['current_round'] # this is the current playing round - # But we want the last COMPLETED round for calculations - # If results show N entries, N rounds are completed + # Completed rounds: use page metadata ("Положение после тура N") + current_round = meta['current_round'] + # Sanity check against actual results + max_results = max(len(s['results']) for s in standings) + if current_round < 2 and max_results > current_round: + current_round = max_results + elif max_results < current_round: + current_round = max_results - # Reconcile: completed rounds = len(results for first player) - if standings and len(standings[0].get('results', [])) < current_round: - current_round = len(standings[0].get('results', [])) - - # Fetch starting list (art=5) for ratings - start_url = base_url + '&art=5&turdet=YES' + # Starting list for ratings try: - html_start = fetch_url(start_url) - players = parse_start_list(html_start) + start_html = fetch_url(base_url + '&art=5&turdet=YES') + start_players = parse_start_list(start_html) except Exception: - players = {} + start_players = {} + + # Match real SNo and rating from start list + # Build name→sno lookup (normalized) + name_to_sno = {} + for sno, info in start_players.items(): + name_to_sno[_normalize_name(info['name'])] = sno - # Match ratings from start list into standings for s in standings: - # Find by name match - for sno, p in players.items(): - if p['name'].lower() == s['name'].lower(): - s['rating'] = p['rating'] - s['starting_sno'] = sno - break - else: + sname = _normalize_name(s['name']) + # Try exact match first + real_sno = name_to_sno.get(sname) + if real_sno is None: + # Fuzzy: find best partial match (first+last name overlap) + sname_parts = set(sname.split()) + best_sno = None + best_overlap = 0 + for sl_name, sl_sno in name_to_sno.items(): + sl_parts = set(sl_name.split()) + overlap = len(sname_parts & sl_parts) + if overlap > best_overlap: + best_overlap = overlap + best_sno = sl_sno + real_sno = best_sno + + s['starting_sno'] = real_sno if real_sno else s.get('starting_sno', s['rank']) + + # Get real rating from start list + if real_sno and real_sno in start_players: + s['rating'] = start_players[real_sno].get('rating', s.get('rating', 0)) + elif 'rating' not in s: s['rating'] = 0 - s['starting_sno'] = s['rank'] + + # Keep original results from standings as backup (for byes/forfeits) + for s in standings: + s['_orig_results'] = list(s.get('results', [])) + s['results'] = [] # Clear — will rebuild from pairings + + # Rebuild results from art=2 pairings (correct SNo, unlike standings cells) + sno_to_standing = {s['starting_sno']: s for s in standings if s.get('starting_sno')} + name_to_standing = {_normalize_name(s['name']): s for s in standings} + + for rd in range(1, current_round + 1): + try: + rd_html = fetch_url(f'{base_url}&art=2&rd={rd}&turdet=YES') + games = parse_round_pairings(rd_html, rd) + except Exception: + continue + + for g in games: + ws, bs = g['white_sno'], g['black_sno'] + ws_result = g.get('white_score') + bs_result = g.get('black_score') + + if ws_result is not None: + # Completed game + ws_val = float(ws_result) + bs_val = float(bs_result) + else: + # Unplayed — skip + continue + + # Resolve players by SNo or name (normalized) + wp = sno_to_standing.get(ws) or name_to_standing.get(_normalize_name(g.get('white_name', ''))) + bp = sno_to_standing.get(bs) or name_to_standing.get(_normalize_name(g.get('black_name', ''))) + + if wp: + opp_sno = bs if bs != 0 else (bp.get('starting_sno') if bp else 0) + existing = [r for r in wp.get('results', []) if r.get('round') != rd] + existing.append({'opponent': opp_sno, 'color': 'w', 'score': ws_val, 'round': rd}) + wp['results'] = existing + + if bp: + opp_sno = ws if ws != 0 else (wp.get('starting_sno') if wp else 0) + existing = [r for r in bp.get('results', []) if r.get('round') != rd] + existing.append({'opponent': opp_sno, 'color': 'b', 'score': bs_val, 'round': rd}) + bp['results'] = existing + + # For byes/forfeits not captured by pairings, use original standings results + for s in standings: + sno = s.get('starting_sno') + if sno and len(s.get('results', [])) < rd: + orig_results = s.get('_orig_results', []) + if len(orig_results) >= rd: + res = orig_results[rd - 1] + res['round'] = rd + s['results'] = list(s.get('results', [])) + s['results'].append(res) + + # Final pass: fill any remaining gaps from _orig_results (forfeit losers, byes) + for s in standings: + existing_rounds = {r.get('round', 0) for r in s.get('results', [])} + orig = s.get('_orig_results', []) + for ri, res in enumerate(orig): + rd_num = ri + 1 + if rd_num <= current_round and rd_num not in existing_rounds: + res_copy = dict(res) + res_copy['round'] = rd_num + if res_copy.get('opponent') and res_copy['opponent'] > 0: + opp_sno = res_copy['opponent'] + if opp_sno not in sno_to_standing: + # Try to normalise opponent SNo via name from standings + for s2 in standings: + if s2['rank'] == opp_sno and s2.get('starting_sno'): + res_copy['opponent'] = s2['starting_sno'] + break + s['results'].append(res_copy) return { 'name': meta['name'], - 'num_rounds': num_rounds if num_rounds else 9, # fallback + 'num_rounds': meta['num_rounds'], 'current_round': current_round, - 'players': players, + 'players': start_players, 'standings': standings, } + + +def parse_start_list(html: str) -> dict: + """Parse starting list from art=0 or art=5 page. + + Two formats supported: + 1. art=0 text: SNo Name FIDE_ID National_ID FED Rating Region + 2. art=5 table: SNo, Name, FED, Rating columns + """ + soup = BeautifulSoup(html, 'html.parser') + + # Try art=0 text format first + text = soup.get_text() + players = {} + + # Pattern: SNo (1-3 digits), Full Name (3 words), two IDs, FED, Rating + pattern = re.compile( + r'(?:^|\s)(\d{1,3})\s+' + r'([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+)' + r'\s+\d+\s+\d+\s+' + r'([A-Z]{3})\s+' + r'(\d{3,4})' + ) + for m in pattern.finditer(text): + sno = int(m.group(1)) + name = m.group(2).strip() + fed = m.group(3) + rating = int(m.group(4)) + players[sno] = {'name': name, 'rating': rating, 'fed': fed} + + if len(players) >= 10: + return players + + # Fallback: try art=5 table format + tables = soup.find_all('table') + for table in tables: + rows = table.find_all('tr') + data_rows = 0 + for row in rows: + cells = row.find_all('td') + texts = [c.get_text(strip=True) for c in cells] + if len(texts) >= 4 and texts[0].isdigit() and _has_cyrillic(' '.join(texts)): + data_rows += 1 + if data_rows < 5: + continue + for row in rows: + cells = row.find_all('td') + texts = [c.get_text(strip=True) for c in cells] + if len(texts) < 4 or not texts[0].isdigit(): + continue + sno = int(texts[0]) + name = '' + fed = '' + rating = 0 + for ci, t in enumerate(texts[1:], 1): + if _has_cyrillic(t) and len(t) > 5 and not name: + name = t + if ci + 1 < len(texts) and re.match(r'^[A-Z]{3}$', texts[ci + 1]): + fed = texts[ci + 1] + continue + if t.isdigit() and len(t) >= 3 and not rating: + rating = int(t) + if name: + players[sno] = {'name': name, 'rating': rating, 'fed': fed} + if players: + break + + return players diff --git a/swiss_calc/swiss.py b/swiss_calc/swiss.py index ca2643d..055fbf1 100644 --- a/swiss_calc/swiss.py +++ b/swiss_calc/swiss.py @@ -3,7 +3,11 @@ FIDE Swiss — упрощённый алгоритм на основе Folding Вместо полного max-weight matching (как в JaVaFo), использует перебор вариантов fold + greedy цветовая оптимизация. -Для post-round-1 (46 игроков с 1pt) даёт >40% совпадений. +Улучшения: +- Учёт предыдущих bye (не даём bye повторно) +- Downfloaters паруются с верхом следующей группы очков +- Абсолютное цветовое предпочтение (|balance| >= 2) — жёсткое правило +- Сортировка по очкам + рейтингу для корректного fold """ from collections import defaultdict @@ -31,6 +35,9 @@ class Player: def color_balance(self): return self.white_count - self.black_count @property def last_color(self): return self.colors[-1] if self.colors else None + @property + def had_bye(self): + return any(r.get('opponent', 0) == 0 for r in self.results) def preferred_color(self) -> str: if not self.colors: @@ -70,18 +77,70 @@ def compute_tiebreakers(players: list, rounds: int) -> None: # ═══════════════════════════════════ def _assign_colors(p1: Player, p2: Player) -> str: - """'w' если p1 белые, 'b' если p1 чёрные.""" + """Return 'w' if p1 gets white, 'b' if p1 gets black. + + FIDE colour allocation rules (C04 Annex E.5): + 1. Absolute preference (|bal| >= 2) MUST be satisfied + 2. Strong preference (|bal| == 1) SHOULD be satisfied + 3. When both have same absolute preference, higher rated gets it + 4. When no absolute conflict, alternate from last round + """ + force1 = p1.color_force() + force2 = p2.color_force() pref1 = p1.preferred_color() pref2 = p2.preferred_color() + + # Rule 1: absolute colour preference is mandatory + if force1 == 2 and force2 < 2: + return pref1 + if force2 == 2 and force1 < 2: + return 'b' if pref2 == 'w' else 'w' + + # Both have absolute preference + if force1 == 2 and force2 == 2: + if pref1 != pref2: + return pref1 # both satisfied + # Both want same colour — higher rated gets preference + if p1.rating >= p2.rating: + return pref1 + else: + return 'b' if pref1 == 'w' else 'w' + + # No absolute preferences — use strong preference, then rating + if force1 > force2: + return pref1 + if force2 > force1: + return 'b' if pref2 == 'w' else 'w' + + # Equal force (0 or 1), maybe same or different preferences if pref1 != pref2: return pref1 - force1, force2 = p1.color_force(), p2.color_force() - if force1 > force2: return pref1 - if force2 > force1: return 'b' if pref1 == 'w' else 'w' - if p1.rating >= p2.rating: return pref1 + + # Same preferences — higher rated decides + if p1.rating >= p2.rating: + return pref1 return 'b' if pref1 == 'w' else 'w' +def _color_score(p1: Player, p2: Player, p1_color: str) -> int: + """Score colour quality for the pair. Higher is better.""" + score = 0 + p1_has_pref = (p1_color == p1.preferred_color()) + p2_has_pref = (('b' if p1_color == 'w' else 'w') == p2.preferred_color()) + + if p1_has_pref: + score += 3 if p1.color_force() >= 2 else 2 + else: + score -= 5 if p1.color_force() >= 2 else 0 + + if p2_has_pref: + score += 3 if p2.color_force() >= 2 else 2 + else: + score -= 5 if p2.color_force() >= 2 else 0 + + return score + + # ═══════════════════════════════════ # ПАРИРОВАНИЕ BRACKET — ПЕРЕБОР OFFSET # ═══════════════════════════════════ @@ -90,57 +149,60 @@ def _pair_bracket_fold_search( players: List[Player], all_paired: set, ) -> Tuple[List[Tuple[Player, Player]], List[Player]]: - """Параметризованный fold с перебором offset и floaters. - - Для bracket размером N: - 1. Если N нечётное — пробуем каждого как флоатера - 2. Для оставшихся M (чётное) — пробуем fold с offset 0..M/2-1 - 3. Выбираем комбинацию с лучшим цветовым качеством + """Fold pairing with offset search and floater candidates. + + For a bracket of size N: + 1. If N odd — try each player as a downfloater + 2. For the remaining M (even) — try fold offsets 0..M/2-1 + 3. Select combination with best colour score + + Sort is by (-points, -rating) so downfloaters (higher score) + naturally end up in S1, pairing with top of S2. """ available = [p for p in players if p.sno not in all_paired] n = len(available) - + if n < 2: return [], list(available) - - available.sort(key=lambda p: -p.rating) - + + available.sort(key=lambda p: (-p.points, -p.rating)) + best_pairs = [] best_floaters = list(available[-1:]) if n % 2 == 1 else [] best_score = -9999 - - # Кандидаты на флоат + + # Floater candidates floater_candidates = [None] if n % 2 == 1: floater_candidates = range(n) - + for fi in floater_candidates: if fi is not None: floater = available[fi] - rest = available[:fi] + available[fi+1:] + rest = available[:fi] + available[fi + 1:] else: floater = None rest = available - + m = len(rest) - - # Перебор offset + + # Try fold with different offsets for offset in range(m // 2): pairs = [] ok = True used = set() - + for i in range(m // 2): a = rest[i] b = rest[m // 2 + ((i + offset) % (m // 2))] - + if a.has_played(b.sno): ok = False break if a.sno in used or b.sno in used: ok = False break - + color = _assign_colors(a, b) if color == 'w': pairs.append((a, b)) @@ -148,33 +210,25 @@ def _pair_bracket_fold_search( pairs.append((b, a)) used.add(a.sno) used.add(b.sno) - + if not ok or len(pairs) < m // 2: continue - - # Оценка: цветовые несовпадения - color_score = 0 - for wp, bp in pairs: - if wp.preferred_color() == 'w': - color_score += 2 - elif wp.color_force() >= 2: - color_score -= 5 - if bp.preferred_color() == 'b': - color_score += 2 - elif bp.color_force() >= 2: - color_score -= 5 - + + # Score colour quality + color_score = sum(_color_score(wp, bp, 'w') for wp, bp in pairs) + if color_score > best_score: best_score = color_score best_pairs = pairs best_floaters = [floater] if floater else [] - + if not best_pairs and n % 2 == 1: - # Фолбэк: float последнего + # Fallback: float the last player floater = available[-1] rest = available[:-1] m = len(rest) best_pairs = [] + best_floaters = [floater] for i in range(m // 2): a, b = rest[i], rest[m // 2 + i] color = _assign_colors(a, b) @@ -182,12 +236,31 @@ def _pair_bracket_fold_search( best_pairs.append((a, b)) else: best_pairs.append((b, a)) - best_floaters = [floater] - + + if not best_pairs: + # Desperate fallback: force-pair even if already played (no other option) + # This can happen when only 2 players in a score group have met before + rest = list(available) + m = len(rest) + if m >= 2: + best_pairs = [] + best_floaters = [] + if m % 2 == 1: + best_floaters = [rest[-1]] + rest = rest[:-1] + m = len(rest) + for i in range(m // 2): + a, b = rest[i], rest[m // 2 + i] + color = _assign_colors(a, b) + if color == 'w': + best_pairs.append((a, b)) + else: + best_pairs.append((b, a)) + for wp, bp in best_pairs: all_paired.add(wp.sno) all_paired.add(bp.sno) - + return best_pairs, best_floaters @@ -196,19 +269,28 @@ def _pair_bracket_fold_search( # ═══════════════════════════════════ def fide_swiss_pairing(players: list, current_round: int) -> list: - """FIDE Swiss pairings через fold с перебором offset.""" + """FIDE Swiss pairings with fold + offset search. + + Players sorted by (-points, -rating). + Score brackets processed from highest to lowest. + Downfloaters paired with the top of the lower bracket. + Bye assigned to lowest-rated player in lowest score group + who hasn't had a bye yet. + """ sorted_players = sorted(players, key=sort_key) - - # Bye + + # Bye — assign to eligible player in lowest score group if len(sorted_players) % 2 == 1: groups = defaultdict(list) for p in sorted_players: groups[p.points].append(p) - lowest = sorted(groups[min(groups.keys())], key=lambda p: p.rating) - bye_player = lowest[0] + # Lowest score group, by rating ascending, excluding previous bye receivers + lowest_group = groups[min(groups.keys())] + lowest_group.sort(key=lambda p: (p.had_bye, p.rating)) + bye_player = lowest_group[0] sorted_players = [p for p in sorted_players if p.sno != bye_player.sno] - - # Score brackets + + # Create score brackets brackets = [] i = 0 while i < len(sorted_players): @@ -218,24 +300,79 @@ def fide_swiss_pairing(players: list, current_round: int) -> list: group.append(sorted_players[i]) i += 1 brackets.append(group) - + all_pairs = [] all_paired = set() downfloaters = [] - + for group in brackets: - group_avail = [p for p in group if p.sno not in all_paired] - for df in downfloaters: - if df.sno not in all_paired: - group_avail.append(df) - - if len(group_avail) < 2: - downfloaters = group_avail + # Available players in this bracket + bracket_avail = [p for p in group if p.sno not in all_paired] + + # Downfloaters from above (have more points than this bracket) + floaters = [df for df in downfloaters if df.sno not in all_paired] + + if len(bracket_avail) + len(floaters) < 2: + downfloaters = bracket_avail + floaters continue - - pairs, downfloaters = _pair_bracket_fold_search(group_avail, all_paired) - all_pairs.extend(pairs) - + + # Priority: pair each downfloater with a bracket member + # (downfloaters get paired with top-rated bracket members) + bracket_avail.sort(key=lambda p: -p.rating) + floaters.sort(key=lambda p: -p.rating) + + allocated = set() + for floater in floaters: + best_idx = None + best_score = -999 + for j, bp in enumerate(bracket_avail): + if bp.sno in allocated: + continue + if floater.has_played(bp.sno): + continue + color = _assign_colors(floater, bp) + score = _color_score(floater, bp, color) + if score > best_score: + best_score = score + best_idx = j + if best_idx is not None: + bp = bracket_avail[best_idx] + color = _assign_colors(floater, bp) + if color == 'w': + all_pairs.append((floater, bp)) + else: + all_pairs.append((bp, floater)) + all_paired.add(floater.sno) + all_paired.add(bp.sno) + allocated.add(bp.sno) + downfloaters = [df for df in downfloaters if df.sno != floater.sno] + + # Remaining bracket members — pair among themselves + remaining = [p for p in bracket_avail if p.sno not in all_paired] + if len(remaining) >= 2: + pairs, new_floaters = _pair_bracket_fold_search(remaining, all_paired) + all_pairs.extend(pairs) + downfloaters = new_floaters + [df for df in downfloaters if df.sno not in all_paired] + elif remaining: + # Odd leftover bracket member + unpaired floaters carry forward + downfloaters = remaining + [df for df in downfloaters if df.sno not in all_paired] + + # Final pass: pair any remaining unpaired players (bottom of the bracket chain) + unpaired = [df for df in downfloaters if df.sno not in all_paired] + if unpaired: + unpaired.sort(key=lambda p: (-p.points, -p.rating)) + # Force-pair all remaining (even if already played — no other option) + if len(unpaired) % 2 == 1: + # Odd remaining: the lowest goes unpaired (caught by calculate_next_round as bye) + unpaired = unpaired[:-1] + for i in range(0, len(unpaired), 2): + a, b = unpaired[i], unpaired[i + 1] + color = _assign_colors(a, b) + if color == 'w': + all_pairs.append((a, b)) + else: + all_pairs.append((b, a)) + return all_pairs @@ -247,39 +384,75 @@ def calculate_next_round(tournament_data: dict) -> dict: standings = tournament_data['standings'] current_round = tournament_data['current_round'] next_round = current_round + 1 - + player_map = {} + sno_to_player = {s.get('starting_sno', s['rank']): None for s in standings} for s in standings: rank = s['rank'] + sno = s.get('starting_sno', rank) p = Player( - sno=s.get('starting_sno', rank), + sno=sno, name=s['name'], rating=s.get('rating', 0), fed=s['fed'], points=s['points'], results=s['results']) p.rank = rank p.tb = s.get('tb', []) player_map[rank] = p - - # Предпочитаем предрассчитанные пары с сайта - has_calculated = any(s.get('next_opponent') for s in standings) - if has_calculated: - pairings = [] - paired = set() - for s in standings: - rank = s['rank'] - if rank in paired: continue - opp_rank = s.get('next_opponent') - if opp_rank and opp_rank in player_map and opp_rank not in paired: - color = s.get('next_color', 'w') - p1, p2 = player_map[rank], player_map[opp_rank] - if color == 'w': - pairings.append((p1, p2, 'w')) + sno_to_player[sno] = p + + # Try bbpPairings first (FIDE 2025 Dutch System engine) + bbp_error = None + try: + from .trf_generator import generate_trf + from .bbp_wrapper import call_bbp + + trf = generate_trf( + tournament_data, next_round, + name=tournament_data.get('name', 'Chess Tournament'), + use_rank=False, + initial_color_white=False, + ) + bbp_pairs, _ = call_bbp(trf) + + if bbp_pairs: + pairings = [] + for w_sno, b_sno in bbp_pairs: + wp = sno_to_player.get(w_sno) + if wp is None: + continue + if b_sno == 0: + pairings.append((wp, wp, 'bye')) else: - pairings.append((p2, p1, 'w')) - paired.add(rank); paired.add(opp_rank) - return {'round': next_round, 'pairings': pairings, - 'players': player_map, 'source': 'chess_results_precalculated'} - + bp = sno_to_player.get(b_sno) + if bp is None: + continue + pairings.append((wp, bp, 'w')) + if pairings: + return { + 'round': next_round, + 'pairings': pairings, + 'players': player_map, + 'source': 'bbp_pairings_fide_2025', + } + except Exception as e: + bbp_error = str(e) + + if bbp_error: + import sys + print(f'⚠️ bbpPairings не сработал ({bbp_error}), использую упрощённый Swiss', file=sys.stderr) + + # Fallback: simplified Swiss algorithm raw = fide_swiss_pairing(list(player_map.values()), current_round) + pairings = [(wp, bp, 'w') for wp, bp in raw] + + # Check for missing players (bye from odd count not returned in raw pairs) + all_snos = set() + for wp, bp in raw: + all_snos.add(wp.sno) + all_snos.add(bp.sno) + for p in player_map.values(): + if p.sno not in all_snos: + pairings.append((p, p, 'bye')) + return {'round': next_round, - 'pairings': [(wp, bp, 'w') for wp, bp in raw], - 'players': player_map, 'source': 'swiss_algorithm'} + 'pairings': pairings, + 'players': player_map, 'source': 'swiss_algorithm_simplified'} diff --git a/swiss_calc/trf_generator.py b/swiss_calc/trf_generator.py new file mode 100644 index 0000000..aaddfd0 --- /dev/null +++ b/swiss_calc/trf_generator.py @@ -0,0 +1,181 @@ +""" +TRF (Tournament Report File) generator for bbpPairings. + +Generates TRF-2026 format from parsed chess-results.com data. +Format specification: FIDE C04 Annex 2 (TRF16). +bbpPairings extensions: XXC rank, XXR , 152 W/B. +""" + +import io +from typing import Optional + + +def _pad_right(text: str, width: int) -> str: + """Pad text to exactly `width` chars, left-aligned.""" + if len(text) > width: + text = text[:width] + return text.ljust(width) + + +def _pad_left(text: str, width: int) -> str: + """Pad text to exactly `width` chars, right-aligned.""" + if len(text) > width: + text = text[-width:] + return text.rjust(width) + + +def _fixed_field(line: list, position: int, value: str): + """Place `value` at 1-indexed `position` in the mutable `line` list.""" + pos = position - 1 + # Ensure line is long enough + while len(line) < pos + len(value): + line.append(' ') + for i, ch in enumerate(value): + line[pos + i] = ch + + +RESULT_CODES = { + 1.0: '1', + 0.5: 'D', # Draw (TRF standard; '=' is not accepted by bbpPairings) + 0.0: '0', +} + +BYE_RESULT_CODES = { + 1.0: '+', # forfeit win / full-point bye + 0.5: 'H', # half-point bye + 0.0: '-', # forfeit loss / zero-point bye +} + + +def generate_trf( + tournament: dict, + next_round: int, + name: str = "Chess Tournament", + use_rank: bool = True, + initial_color_white: bool = True, +) -> str: + """Generate TRF file content for bbpPairings. + + Args: + tournament: Parsed tournament data from parser.fetch_tournament(). + next_round: Round number to pair (e.g., 4 if rounds 1-3 are complete). + name: Tournament name. + use_rank: If True, use XXC rank (rank from standings for ordering). + initial_color_white: If True, top player gets white in round 1. + + Returns: + TRF file content as string. + """ + buf = io.StringIO() + num_rounds = tournament.get('num_rounds', next_round) + + # Tournament header + buf.write(f"012 {name}\n") + + # Configuration lines + config_parts = [] + if use_rank: + config_parts.append("rank") + if initial_color_white: + config_parts.append("white1") + else: + config_parts.append("black1") + if config_parts: + buf.write(f"XXC {' '.join(config_parts)}\n") + + # Players — sort by actual points (desc) then starting_sno for stable order. + # The order in the TRF file matters: bbpPairings uses it as fallback ordering + # within score brackets when ratings are unavailable. + standings = sorted( + tournament['standings'], + key=lambda s: (-s['points'], s.get('starting_sno', s['rank'])) + ) + for s in standings: + # Build a line as a list of chars (mutable) + line = [' '] * 200 + + # Position 1-3: Data identification (001) + _fixed_field(line, 1, '001') + + # Position 5-8: Starting rank number (right-aligned, 4 chars) + sno = s.get('starting_sno', s['rank']) + _fixed_field(line, 5, _pad_left(str(sno), 4)) + + # Position 10: Sex (m/w) + # Not available from chess-results, leave empty + + # Position 11-13: Title + # Not available from chess-results, leave empty + + # Position 15-47: Name (33 chars) + # chess-results gives "Last, First" or "Last First" format + player_name = s['name'] + if ',' in player_name: + # Already "Last, First" + pass + else: + # Try to split: last word = last name, rest = first name + parts = player_name.split() + if len(parts) >= 2: + player_name = f"{parts[0]}, {' '.join(parts[1:])}" + _fixed_field(line, 15, _pad_right(player_name, 33)) + + # Position 49-52: Rating (4 chars, right-aligned) + rating = s.get('rating', 0) + if rating: + _fixed_field(line, 49, _pad_left(str(rating), 4)) + + # Position 54-56: Federation (3 chars) + fed = s.get('fed', '') + if fed: + _fixed_field(line, 54, _pad_right(fed[:3], 3)) + + # Position 81-84: Points (4 chars, right-aligned) + points = s['points'] + pts_str = f"{points:.1f}" + _fixed_field(line, 81, _pad_left(pts_str, 4)) + + # Position 86-89: Rank (4 chars, right-aligned) + # IMPORTANT: this MUST be the initial/stable rank (starting SNo), + # NOT the current standings position. bbpPairings uses this + # for ordering within score brackets when XXC rank is set. + _fixed_field(line, 86, _pad_left(str(sno), 4)) + + # Round results (starting at position 92, each round = 10 chars) + results = s.get('results', []) + sno = s.get('starting_sno', s['rank']) + for ri, r in enumerate(results): + base_pos = 92 + ri * 10 + opp = r.get('opponent', r.get('opponent_sno', 0)) + color = r.get('color', '-') + score = r.get('score', 0) + + # Skip self-matches (data artifacts on chess-results.com) + if opp == sno: + opp = 0 + + if opp > 0: + # Regular game + # Position 92+ri*10 to 92+ri*10+3: opponent SNo (4 chars) + _fixed_field(line, base_pos, _pad_left(str(opp), 4)) + # Position 92+ri*10+5: color (w/b) + _fixed_field(line, base_pos + 5, color) + # Position 92+ri*10+7: result code + result_char = RESULT_CODES.get(score, 'Z') + _fixed_field(line, base_pos + 7, result_char) + else: + # Bye / forfeit / unpaired + _fixed_field(line, base_pos, '0000') + _fixed_field(line, base_pos + 5, '-') + result_char = BYE_RESULT_CODES.get(score, '-') + _fixed_field(line, base_pos + 7, result_char) + + # Trim trailing spaces + line_str = ''.join(line).rstrip() + if line_str.strip(): + buf.write(line_str + '\n') + + # Total rounds (XXR = total number of rounds in tournament) + buf.write(f"XXR {num_rounds}\n") + + return buf.getvalue() diff --git a/test_all_rounds.py b/test_all_rounds.py new file mode 100644 index 0000000..aae1884 --- /dev/null +++ b/test_all_rounds.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""End-to-end test: parse rounds 1-4, calculate round 5, compare with site.""" + +import sys +sys.path.insert(0, '.') + +from swiss_calc.parser import fetch_url, parse_round_pairings, fetch_tournament +from swiss_calc.swiss import calculate_next_round + +BASE = 'https://s1.chess-results.com/tnr1393124.aspx?lan=11' + +print("=" * 60) +print("ТЕСТ: парсинг туров 1-4 → расчёт тура 5 vs chess-results.com") +print("=" * 60) + +# 1. Parse tournament (rounds 1-4 from standings) +print("\n📥 Парсим турнир...") +data = fetch_tournament(BASE) +print(f" Турнир: {data['name'][:60]}") +print(f" Сыграно туров: {data['current_round']}") +print(f" Участников: {len(data['standings'])}") + +# Show first few players' data +for s in data['standings'][:3]: + results_str = ' '.join(f"{r['opponent']}{r['color']}{r['score']}" for r in s['results']) + print(f" #{s['rank']} {s['name'][:30]}: {s['points']}pts, results=[{results_str}]") + +# 2. Calculate round 5 +print(f"\n🧮 Считаем тур {data['current_round'] + 1}...") +result = calculate_next_round(data) +print(f" Источник: {result['source']}") +calc_pairs = result['pairings'] +print(f" Рассчитано пар: {len(calc_pairs)}") + +# 3. Fetch actual round 5 from chess-results +print(f"\n📥 Парсим реальные пары тура 5 с сайта...") +html5 = fetch_url(f'{BASE}&art=2&rd=5&turdet=YES') +real_games = parse_round_pairings(html5, 5) +print(f" Всего игр на сайте: {len(real_games)}") + +# Build sets for comparison +calc_set = set() +for wp, bp, color in calc_pairs: + sno_w = getattr(wp, 'sno', wp.get('starting_sno', 0)) if not hasattr(wp, 'sno') else wp.sno + sno_b = getattr(bp, 'sno', bp.get('starting_sno', 0)) if not hasattr(bp, 'sno') else bp.sno + calc_set.add((min(sno_w, sno_b), max(sno_w, sno_b))) + +real_set = set() +for g in real_games: + w, b = g['white_sno'], g['black_sno'] + real_set.add((min(w, b), max(w, b))) + +# 4. Compare +matched = calc_set & real_set +extra_calc = calc_set - real_set +extra_real = real_set - calc_set + +print(f"\n📊 СРАВНЕНИЕ:") +print(f" Совпало пар: {len(matched)}/{len(real_games)} ({100*len(matched)/max(len(real_games),1):.0f}%)") +print(f" Лишних в расчёте: {len(extra_calc)}") +print(f" Лишних на сайте: {len(extra_real)}") + +if extra_calc: + print(f"\n Пары только в расчёте (первые 5):") + for w, b in list(extra_calc)[:5]: + print(f" #{w} vs #{b}") + +if extra_real: + print(f"\n Пары только на сайте (первые 5):") + for w, b in list(extra_real)[:5]: + print(f" #{w} vs #{b}") + +# 5. Show top boards side by side +print(f"\n📋 ДОСКИ 1-5: расчёт vs сайт") +calc_by_board = {} +for i, (wp, bp, color) in enumerate(calc_pairs): + sno_w = wp.sno + sno_b = bp.sno + calc_by_board[f"#{sno_w} #{wp.name[:20]}"] = f"#{sno_b} {bp.name[:20]}" + +for g in real_games[:5]: + b = g['board'] + w_name = g['white_name'][:25] + b_name = g['black_name'][:25] + ws, bs = g['white_sno'], g['black_sno'] + # Find matching calc pair + pair_key = (min(ws, bs), max(ws, bs)) + status = "✅" if pair_key in matched else "❌" + print(f" Доска {b}: #{ws} {w_name} vs #{bs} {b_name} {status}") + +# 6. Check Vrubel specifically +print(f"\n🎯 Врубель Константин:") +for wp, bp, color in calc_pairs: + if 'Врубель' in wp.name: + print(f" Расчёт: #{wp.sno} {wp.name} (белые) vs #{bp.sno} {bp.name}") + break +for g in real_games: + if 'Врубель' in g.get('white_name', '') or 'Врубель' in g.get('black_name', ''): + if 'Врубель' in g['white_name']: + print(f" Сайт: #{g['white_sno']} {g['white_name']} (белые) vs #{g['black_sno']} {g['black_name']}") + else: + print(f" Сайт: #{g['black_sno']} {g['black_name']} (чёрные) vs #{g['white_sno']} {g['white_name']}") + break diff --git a/test_round_by_round.py b/test_round_by_round.py new file mode 100644 index 0000000..0362b58 --- /dev/null +++ b/test_round_by_round.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Test round-by-round: parse once, truncate results, calculate next round.""" + +import sys +sys.path.insert(0, '.') + +from swiss_calc.parser import fetch_tournament, fetch_url, parse_round_pairings +from swiss_calc.trf_generator import generate_trf +from swiss_calc.bbp_wrapper import call_bbp +import re + +def norm(n): + return re.sub(r'\s+', ' ', n.replace(',', '')).strip() + +BASE = 'https://s1.chess-results.com/tnr1289471.aspx?lan=11' + +print("=" * 60) +print("ТЕСТ: пошаговый расчёт всех туров") +print("=" * 60) + +# Parse once — get full data with correct SNo, byes, forfeits +data = fetch_tournament(BASE) +total_rounds = data['current_round'] +standings = data['standings'] + +print(f"Турнир: {data['name'][:60]}") +print(f"Сыграно туров: {total_rounds}") +print(f"Участников: {len(standings)}") + +# Collect actual pairs for comparison +name_to_sno = {norm(s['name']): s['starting_sno'] for s in standings} +actual_pairs = {} +for rd in range(1, total_rounds + 1): + html = fetch_url(f'{BASE}&art=2&rd={rd}&turdet=YES') + games = parse_round_pairings(html, rd) + for g in games: + if g['white_sno'] == 0: + g['white_sno'] = name_to_sno.get(norm(g['white_name']), 0) + if g['black_sno'] == 0: + g['black_sno'] = name_to_sno.get(norm(g['black_name']), 0) + actual_pairs[rd] = games + print(f" Тур {rd}: {len(games)} пар") + +print(f"\n{'='*60}") +print(f"ПОШАГОВЫЙ РАСЧЁТ") +print(f"{'='*60}") + +overall_matched = 0 +overall_total = 0 + +for next_rd in range(2, total_rounds + 1): + completed = next_rd - 1 + + # Truncate results to only first `completed` rounds + truncated_standings = [] + for s in standings: + full_results = s.get('results', []) + full_orig = s.get('_orig_results', []) + + # Take results only up to round `completed` + trunc_results = [r for r in full_results if r.get('round', 99) <= completed] + trunc_orig = [r for r in full_orig if isinstance(r, dict) and r.get('round', 99) <= completed] + + points = sum(r['score'] for r in trunc_results) + + truncated_standings.append({ + 'rank': s['rank'], + 'name': s['name'], + 'fed': s['fed'], + 'points': points, + 'starting_sno': s['starting_sno'], + 'rating': s.get('rating', 0), + 'results': trunc_results, + '_orig_results': trunc_orig, + 'tb': s.get('tb', []), + }) + + truncated = { + 'name': data['name'], + 'num_rounds': total_rounds, + 'current_round': completed, + 'players': data['players'], + 'standings': truncated_standings, + } + + trf = generate_trf(truncated, next_rd, name=data.get('name', 'Test'), + use_rank=True, initial_color_white=True) + try: + bbp_pairs, _ = call_bbp(trf) + except Exception as e: + print(f" Тур {next_rd}: bbpPairings ОШИБКА — {e}") + continue + + # Compare with actual + calc_set = set() + for w_sno, b_sno in bbp_pairs: + if b_sno == 0 or w_sno == 0: + continue + calc_set.add((min(w_sno, b_sno), max(w_sno, b_sno))) + + real_set = set() + for g in actual_pairs.get(next_rd, []): + w, b = g['white_sno'], g['black_sno'] + if w == 0 or b == 0: + continue + # Only include completed games (not future pairings) + if g.get('white_score') is not None: + real_set.add((min(w, b), max(w, b))) + + matched = len(calc_set & real_set) + total = len(real_set) + if total == 0: + print(f" Тур {next_rd}: нет сыгранных пар для сравнения") + continue + + pct = 100 * matched / total + overall_matched += matched + overall_total += total + + bar = '█' * int(pct / 5) + '░' * (20 - int(pct / 5)) + print(f" Тур {next_rd}: {matched}/{total} совпадений [{bar}] {pct:.0f}%") + +print(f"\n{'='*60}") +if overall_total > 0: + print(f"ИТОГО: {overall_matched}/{overall_total} ({100*overall_matched/overall_total:.0f}%)") +else: + print(f"ИТОГО: 0 пар") +print(f"{'='*60}")