Основные изменения: - swiss.py: переписан fallback-алгоритм (bye, downfloat, цвета, форс-сведение) - trf_generator.py: генератор TRF-16 для bbpPairings - bbp_wrapper.py: обёртка subprocess для bbpPairings.exe - parser.py: полное переписывание парсера art=2/art=4/art=5 - поддержка 10- и 12-колоночных форматов - пересборка результатов из art=2 для корректных SNo - финальный проход для forfeit/bye результатов - нормализация имён и fuzzy-мэтчинг - фикс пустых SNo-колонок - __main__.py: починен JSON-вывод, поддержка bye - display.py: отображение bye и источника расчёта Ключевой фикс точности: убран XXC rank из TRF — bbpPairings теперь использует порядок из турнирной таблицы вместо поля Rank. Проверено на 5 турнирах (4 из 5 — 100%, 1 — 85% из-за ручных bye).
181 lines
5.9 KiB
Python
181 lines
5.9 KiB
Python
"""
|
|
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 <round>, 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()
|