""" 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()