""" FIDE Swiss — упрощённый алгоритм на основе Folding с перебором offset. Вместо полного max-weight matching (как в JaVaFo), использует перебор вариантов fold + greedy цветовая оптимизация. Улучшения: - Учёт предыдущих bye (не даём bye повторно) - Downfloaters паруются с верхом следующей группы очков - Абсолютное цветовое предпочтение (|balance| >= 2) — жёсткое правило - Сортировка по очкам + рейтингу для корректного fold """ 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 @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: 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: """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 # 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 # ═══════════════════════════════════ def _pair_bracket_fold_search( players: List[Player], all_paired: set, ) -> Tuple[List[Tuple[Player, Player]], List[Player]]: """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.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:] else: floater = None rest = available m = len(rest) # 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)) else: pairs.append((b, a)) used.add(a.sno) used.add(b.sno) if not ok or len(pairs) < m // 2: continue # 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: # 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) if color == 'w': best_pairs.append((a, b)) else: best_pairs.append((b, a)) 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 # ═══════════════════════════════════ # ОСНОВНОЙ АЛГОРИТМ # ═══════════════════════════════════ def fide_swiss_pairing(players: list, current_round: int) -> list: """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 — 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 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] # Create 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: # 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 # 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 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 = {} 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=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 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: 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': pairings, 'players': player_map, 'source': 'swiss_algorithm_simplified'}