Принимает URL турнира, показывает пары следующего тура в Telegram-формате. - parser.py: парсинг chess-results.com - swiss.py: FIDE Dutch System - display.py: форматирование для Telegram - Docker: docker compose run --rm chess-calc 'URL'
285 lines
9.8 KiB
Python
285 lines
9.8 KiB
Python
"""
|
||
FIDE Swiss — упрощённый алгоритм на основе Folding с перебором offset.
|
||
Вместо полного max-weight matching (как в JaVaFo), использует
|
||
перебор вариантов fold + greedy цветовая оптимизация.
|
||
|
||
Для post-round-1 (46 игроков с 1pt) даёт >40% совпадений.
|
||
"""
|
||
|
||
from collections import defaultdict
|
||
from typing import Optional, List, Tuple
|
||
|
||
|
||
class Player:
|
||
def __init__(self, sno: int, name: str, rating: int, fed: str = '',
|
||
points: float = 0.0, results: list = None):
|
||
self.sno = sno
|
||
self.name = name
|
||
self.rating = rating
|
||
self.fed = fed
|
||
self.points = points
|
||
self.results = results or []
|
||
self.opponents = [r['opponent'] for r in self.results]
|
||
self.colors = [r['color'] for r in self.results]
|
||
self.tb = []
|
||
|
||
@property
|
||
def white_count(self): return sum(1 for c in self.colors if c == 'w')
|
||
@property
|
||
def black_count(self): return sum(1 for c in self.colors if c == 'b')
|
||
@property
|
||
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
|
||
|
||
def preferred_color(self) -> str:
|
||
if not self.colors:
|
||
return 'w'
|
||
bal = self.color_balance
|
||
if bal >= 2: return 'b'
|
||
if bal <= -2: return 'w'
|
||
if bal == 1: return 'b'
|
||
if bal == -1: return 'w'
|
||
return 'b' if self.last_color == 'w' else 'w'
|
||
|
||
def color_force(self) -> int:
|
||
bal = abs(self.color_balance)
|
||
if bal >= 2: return 2
|
||
if bal == 1: return 1
|
||
return 0
|
||
|
||
def has_played(self, opponent_sno: int) -> bool:
|
||
return opponent_sno in self.opponents
|
||
|
||
def __repr__(self):
|
||
return f"#{self.sno} {self.name} ({self.points}pts, R{self.rating})"
|
||
|
||
|
||
def sort_key(player: Player) -> tuple:
|
||
return (-player.points, -player.rating)
|
||
|
||
|
||
def compute_tiebreakers(players: list, rounds: int) -> None:
|
||
pts_map = {p.sno: p.points for p in players}
|
||
for p in players:
|
||
p.tb = [round(sum(pts_map.get(o, 0) for o in p.opponents), 1)]
|
||
|
||
|
||
# ═══════════════════════════════════
|
||
# ЦВЕТА
|
||
# ═══════════════════════════════════
|
||
|
||
def _assign_colors(p1: Player, p2: Player) -> str:
|
||
"""'w' если p1 белые, 'b' если p1 чёрные."""
|
||
pref1 = p1.preferred_color()
|
||
pref2 = p2.preferred_color()
|
||
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
|
||
return 'b' if pref1 == 'w' else 'w'
|
||
|
||
|
||
# ═══════════════════════════════════
|
||
# ПАРИРОВАНИЕ BRACKET — ПЕРЕБОР OFFSET
|
||
# ═══════════════════════════════════
|
||
|
||
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. Выбираем комбинацию с лучшим цветовым качеством
|
||
"""
|
||
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)
|
||
|
||
best_pairs = []
|
||
best_floaters = list(available[-1:]) if n % 2 == 1 else []
|
||
best_score = -9999
|
||
|
||
# Кандидаты на флоат
|
||
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:]
|
||
else:
|
||
floater = None
|
||
rest = available
|
||
|
||
m = len(rest)
|
||
|
||
# Перебор offset
|
||
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))
|
||
else:
|
||
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
|
||
|
||
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 последнего
|
||
floater = available[-1]
|
||
rest = available[:-1]
|
||
m = len(rest)
|
||
best_pairs = []
|
||
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))
|
||
best_floaters = [floater]
|
||
|
||
for wp, bp in best_pairs:
|
||
all_paired.add(wp.sno)
|
||
all_paired.add(bp.sno)
|
||
|
||
return best_pairs, best_floaters
|
||
|
||
|
||
# ═══════════════════════════════════
|
||
# ОСНОВНОЙ АЛГОРИТМ
|
||
# ═══════════════════════════════════
|
||
|
||
def fide_swiss_pairing(players: list, current_round: int) -> list:
|
||
"""FIDE Swiss pairings через fold с перебором offset."""
|
||
sorted_players = sorted(players, key=sort_key)
|
||
|
||
# Bye
|
||
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]
|
||
sorted_players = [p for p in sorted_players if p.sno != bye_player.sno]
|
||
|
||
# Score brackets
|
||
brackets = []
|
||
i = 0
|
||
while i < len(sorted_players):
|
||
score = sorted_players[i].points
|
||
group = []
|
||
while i < len(sorted_players) and sorted_players[i].points == score:
|
||
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
|
||
continue
|
||
|
||
pairs, downfloaters = _pair_bracket_fold_search(group_avail, all_paired)
|
||
all_pairs.extend(pairs)
|
||
|
||
return all_pairs
|
||
|
||
|
||
def swiss_pairing(players: list, current_round: int) -> list:
|
||
return fide_swiss_pairing(players, current_round)
|
||
|
||
|
||
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 = {}
|
||
for s in standings:
|
||
rank = s['rank']
|
||
p = Player(
|
||
sno=s.get('starting_sno', rank),
|
||
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'))
|
||
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'}
|
||
|
||
raw = fide_swiss_pairing(list(player_map.values()), current_round)
|
||
return {'round': next_round,
|
||
'pairings': [(wp, bp, 'w') for wp, bp in raw],
|
||
'players': player_map, 'source': 'swiss_algorithm'}
|