/
keorlov
/
aichess
Обзор
Документация
Войти
/
keorlov
/
aichess
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/report.py
494 строки
17 KB
Konstantin Orlov
wip
06 июн 2026, 13:19
06 июн 2026, 13:19
cb645b7
Код
Авторство
О чём код?
"""HTML report generation — dark-themed tournament report with per-game pages.""" import datetime import html import json import os from typing import Dict, List, Optional import chess from .match import GameResult def _e(value) -> str: """HTML-escape a value for safe interpolation into HTML.""" return html.escape(str(value), quote=True) def generate_html_report( rounds: List[List], engine_names: List[str], tournament_type: str, config: dict, output_path: str, swiss_standings: Optional[List[dict]] = None, ): """Generate the main tournament report and per-game pages. Args: rounds: List of rounds, each round is a list of MatchResult objects. engine_names: Names of all engines. tournament_type: "round-robin" or "swiss". config: The full configuration dict. output_path: Where to write the main HTML file. swiss_standings: Pre-computed standings for Swiss tournaments (required for Swiss — ensures consistency with the Swiss handler's state and avoids divergent independent recalculation). """ results_dir = os.path.dirname(output_path) games_dir = os.path.join(results_dir, "games") os.makedirs(games_dir, exist_ok=True) all_matches = [m for rd in rounds for m in rd] all_games = [g for m in all_matches for g in m.games] if swiss_standings is not None: standings = swiss_standings else: standings = _compute_standings(rounds, engine_names) game_paths = _write_game_pages(all_games, games_dir) total_games = len(all_games) wins_as_white = sum(1 for g in all_games if g.result == "1-0") wins_as_black = sum(1 for g in all_games if g.result == "0-1") draws = sum(1 for g in all_games if g.result == "1/2-1/2") double_errors = sum(1 for g in all_games if g.result == "0-0") decisive = total_games - draws - double_errors draw_rate = draws / total_games * 100 if total_games > 0 else 0 avg_moves = sum(g.num_moves for g in all_games) / total_games if total_games > 0 else 0 illegal_losses = sum(1 for g in all_games if g.termination == "illegal move") timeout_losses = sum(1 for g in all_games if g.termination == "timeout") checkmates = sum(1 for g in all_games if g.termination == "checkmate") ACCENT = "#0ea5e9" BG = "#0f172a" CARD = "#1e293b" BORDER = "#334155" MUTED = "#94a3b8" TEXT = "#e2e8f0" html = f"""<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Chess AI Tournament Report</title> <style> *, *::before, *::after {{ box-sizing: border-box; }} body {{ background: {BG}; color: {TEXT}; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; margin: 0; padding: 24px 16px; min-height: 100vh; }} .container {{ max-width: 1000px; margin: 0 auto; }} h1 {{ color: {ACCENT}; font-size: 2rem; margin-bottom: 4px; }} .subtitle {{ color: {MUTED}; font-size: 0.9rem; margin-bottom: 32px; }} h2 {{ color: {ACCENT}; font-size: 1.3rem; margin: 32px 0 12px; border-bottom: 2px solid {CARD}; padding-bottom: 6px; }} .stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-bottom: 24px; }} .stat-card {{ background: {CARD}; border: 1px solid {BORDER}; border-radius: 10px; padding: 16px 20px; }} .stat-card .label {{ color: {MUTED}; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.5px; }} .stat-card .value {{ color: {ACCENT}; font-size: 1.8rem; font-weight: 700; margin-top: 4px; }} table {{ width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 0.9rem; }} th, td {{ padding: 10px 14px; text-align: left; border-bottom: 1px solid {CARD}; }} th {{ color: {ACCENT}; font-weight: 600; background: {CARD}; position: sticky; top: 0; }} tr:hover td {{ background: {CARD}88; }} .rank-col {{ width: 40px; text-align: center; }} .pts-col {{ font-weight: 700; color: {ACCENT}; }} .result-W {{ color: #22c55e; }} .result-L {{ color: {ACCENT}; }} .result-D {{ color: #f59e0b; }} .round-header {{ color: {ACCENT}; font-weight: 600; font-size: 1rem; margin: 24px 0 8px; }} .round-section {{ margin-bottom: 32px; }} .game-link {{ color: {ACCENT}; text-decoration: none; }} .game-link:hover {{ text-decoration: underline; }} footer {{ color: #475569; font-size: 0.8rem; text-align: center; margin-top: 48px; padding-top: 16px; border-top: 1px solid {CARD}; }} </style> </head> <body> <div class="container"> <h1>Chess AI Tournament</h1> <p class="subtitle">Generated {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')} — Type: {tournament_type.upper()} — {len(engine_names)} engines — {total_games} games</p> <h2>Final Standings</h2> <table> <thead> <tr> <th class="rank-col">#</th> <th>Engine</th> <th>Points</th> <th>Wins</th> <th>Draws</th> <th>Losses</th> {"<th>Buchholz</th>" if tournament_type == "swiss" else ""} </tr> </thead> <tbody> """ for rank, s in enumerate(standings, 1): buch_col = f"<td>{s.get('buchholz', 0):.1f}</td>" if tournament_type == "swiss" else "" html += f"""<tr> <td class="rank-col">{rank}</td> <td>{_e(s['name'])}</td> <td class="pts-col">{s['points']:.1f}</td> <td>{s['wins']}</td> <td>{s['draws']}</td> <td>{s['losses']}</td> {buch_col} </tr> """ html += """</tbody></table> <h2>Tournament Statistics</h2> <div class="stats-grid"> """ stats = [ ("Total Games", str(total_games)), ("Decisive Games", str(decisive)), ("Draws", str(draws)), ("Double Forfeits", str(double_errors)), ("Draw Rate", f"{draw_rate:.1f}%"), ("Avg Moves/Game", f"{avg_moves:.1f}"), ("White Wins", str(wins_as_white)), ("Black Wins", str(wins_as_black)), ("Checkmates", str(checkmates)), ("Timeout Losses", str(timeout_losses)), ("Illegal Moves", str(illegal_losses)), ] for label, value in stats: html += f"""<div class="stat-card"> <div class="label">{label}</div> <div class="value">{value}</div> </div> """ html += """</div> <h2>Games by Round</h2> """ game_idx = 0 for round_idx, round_matches in enumerate(rounds, 1): html += f"""<div class="round-section"> <div class="round-header">Round {round_idx}</div> <table> <thead> <tr> <th>White</th> <th>Black</th> <th>Result</th> <th>Termination</th> <th>Moves</th> <th></th> </tr> </thead> <tbody> """ for match in round_matches: for g in match.games: link = f'<a class="game-link" href="{_e(game_paths[game_idx])}">view</a>' html += f"""<tr> <td>{_e(g.white)}</td> <td>{_e(g.black)}</td> <td>{_e(g.result)}</td> <td>{_e(g.termination)}</td> <td style="text-align:right">{g.num_moves}</td> <td>{link}</td> </tr> """ game_idx += 1 html += "</tbody></table></div>\n" html += """ <footer> Chess AI Tournament — built with python-chess </footer> </div> </body> </html>""" with open(output_path, "w", encoding="utf-8") as f: f.write(html) def _write_game_pages(all_games: List[GameResult], games_dir: str) -> List[str]: """Write individual game HTML pages with chessboard and PGN. Returns list of relative paths (from the results dir) for each game. """ paths = [] for idx, game in enumerate(all_games): filename = f"game_{idx + 1:04d}.html" filepath = os.path.join(games_dir, filename) _write_game_page(game, idx + 1, filepath) paths.append(f"games/{filename}") return paths def _write_game_page(game: GameResult, game_num: int, filepath: str): """Write a single game HTML page with interactive chessboard and move slider.""" board = chess.Board() fens = [board.fen()] san_moves: list[str] = [] notation_errors = False if game.pgn: for san in game.pgn.split(): try: move = board.parse_san(san) board.push(move) fens.append(board.fen()) san_moves.append(san) except ValueError: notation_errors = True break fens_json = json.dumps(fens) ACCENT = "#0ea5e9" BG = "#0f172a" CARD = "#1e293b" BORDER = "#334155" MUTED = "#94a3b8" TEXT = "#e2e8f0" LIGHT = "#e8dcc8" DARK = "#7c945c" WHITE_PIECE = "#f8fafc" BLACK_PIECE = "#0f172a" html = f"""<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Game {game_num}: {_e(game.white)} vs {_e(game.black)}</title> <style> *, *::before, *::after {{ box-sizing: border-box; }} body {{ background: {BG}; color: {TEXT}; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; margin: 0; padding: 24px 16px; min-height: 100vh; }} .container {{ max-width: 800px; margin: 0 auto; }} a {{ color: {ACCENT}; text-decoration: none; }} a:hover {{ text-decoration: underline; }} h1 {{ color: {ACCENT}; font-size: 1.5rem; margin-bottom: 8px; }} .meta {{ color: {MUTED}; font-size: 1rem; margin-bottom: 24px; line-height: 1.6; }} .meta span {{ color: {TEXT}; }} .back {{ margin-bottom: 24px; display: inline-block; }} .layout {{ display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; }} .board-col {{ flex-shrink: 0; }} .chessboard {{ display: grid; grid-template-columns: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr); width: 400px; height: 400px; border: 2px solid {BORDER}; border-radius: 4px; overflow: hidden; }} .sq {{ display: flex; align-items: center; justify-content: center; font-size: 2.4rem; line-height: 1; user-select: none; }} .sq-light {{ background: {LIGHT}; }} .sq-dark {{ background: {DARK}; }} .piece-w {{ color: {WHITE_PIECE}; text-shadow: 0 0 2px rgba(0,0,0,0.5); }} .piece-b {{ color: {BLACK_PIECE}; text-shadow: 0 0 2px rgba(255,255,255,0.3); }} .controls {{ margin-top: 12px; display: flex; align-items: center; gap: 8px; }} .controls button {{ background: {CARD}; color: {ACCENT}; border: 1px solid {BORDER}; border-radius: 6px; padding: 6px 12px; font-size: 0.9rem; cursor: pointer; }} .controls button:hover {{ background: {BORDER}; }} .controls button:disabled {{ opacity: 0.4; cursor: default; }} .controls input[type=range] {{ flex: 1; accent-color: {ACCENT}; }} .move-counter {{ color: {MUTED}; font-size: 0.85rem; min-width: 80px; text-align: center; }} .move-list {{ flex: 1; min-width: 280px; background: {CARD}; border: 1px solid {BORDER}; border-radius: 10px; padding: 20px; max-height: 440px; overflow-y: auto; }} .move-list h3 {{ color: {ACCENT}; margin-top: 0; }} .move-list p {{ line-height: 1.8; }} .move-num {{ color: {MUTED}; font-size: 0.85rem; }} .move-link {{ color: {TEXT}; cursor: pointer; text-decoration: none; }} .move-link:hover {{ color: {ACCENT}; text-decoration: underline; }} .move-link.active {{ color: {ACCENT}; font-weight: 700; }} footer {{ color: #475569; font-size: 0.8rem; text-align: center; margin-top: 48px; }} </style> </head> <body> <div class="container"> <a class="back" href="../tournament_report.html">← Back to report</a> <h1>Game {game_num}: {_e(game.white)} vs {_e(game.black)}</h1> <div class="meta"> Result: <span>{_e(game.result)}</span> — Termination: <span>{_e(game.termination)}</span> — Moves: <span>{game.num_moves}</span> </div> <div class="layout"> <div class="board-col"> <div class="chessboard" id="board"></div> <div class="controls"> <button id="btn-prev" title="Previous move">←</button> <input type="range" id="move-slider" min="0" max="{len(fens) - 1}" value="0"> <button id="btn-next" title="Next move">→</button> <span class="move-counter" id="move-label">Start</span> </div> </div> <div class="move-list"> <h3>Moves</h3> <p id="move-list-content">{_build_move_list_html(san_moves, notation_errors)}</p> </div> </div> <footer>Chess AI Tournament — python-chess</footer> </div> <script> const FENS = {fens_json}; const PIECE_SYMBOLS = {{ 'P': '\\u2659', 'N': '\\u2658', 'B': '\\u2657', 'R': '\\u2656', 'Q': '\\u2655', 'K': '\\u2654', 'p': '\\u265F', 'n': '\\u265E', 'b': '\\u265D', 'r': '\\u265C', 'q': '\\u265B', 'k': '\\u265A' }}; let currentIdx = {len(fens) - 1}; let totalMoves = {len(fens) - 1}; function renderBoard(idx) {{ currentIdx = Math.max(0, Math.min(idx, totalMoves)); const fen = FENS[currentIdx]; const parts = fen.split(' '); const rows = parts[0].split('/'); const boardEl = document.getElementById('board'); boardEl.innerHTML = ''; for (let r = 0; r < 8; r++) {{ const row = rows[r]; let file = 0; for (const ch of row) {{ if (ch >= '1' && ch <= '8') {{ const empty = parseInt(ch); for (let i = 0; i < empty; i++) {{ const isLight = (r + file) % 2 === 0; boardEl.innerHTML += '<div class="sq ' + (isLight ? 'sq-light' : 'sq-dark') + '"></div>'; file++; }} }} else {{ const isLight = (r + file) % 2 === 0; const cl = ch === ch.toUpperCase() ? 'piece-w' : 'piece-b'; const sym = PIECE_SYMBOLS[ch] || ''; boardEl.innerHTML += '<div class="sq ' + (isLight ? 'sq-light' : 'sq-dark') + ' ' + cl + '">' + sym + '</div>'; file++; }} }} }} document.getElementById('move-slider').value = currentIdx; document.getElementById('move-label').textContent = currentIdx === 0 ? 'Start' : 'Move ' + currentIdx + ' / ' + totalMoves; document.getElementById('btn-prev').disabled = currentIdx === 0; document.getElementById('btn-next').disabled = currentIdx === totalMoves; // Highlight current move in move list document.querySelectorAll('.move-link').forEach(el => {{ el.classList.toggle('active', parseInt(el.dataset.idx) === currentIdx); }}); }} document.getElementById('btn-prev').addEventListener('click', () => renderBoard(currentIdx - 1)); document.getElementById('btn-next').addEventListener('click', () => renderBoard(currentIdx + 1)); document.getElementById('move-slider').addEventListener('input', function() {{ renderBoard(parseInt(this.value)); }}); document.addEventListener('keydown', function(e) {{ if (e.key === 'ArrowLeft') {{ e.preventDefault(); renderBoard(currentIdx - 1); }} if (e.key === 'ArrowRight') {{ e.preventDefault(); renderBoard(currentIdx + 1); }} }}); // Click on move links to jump document.querySelectorAll('.move-link').forEach(el => {{ el.addEventListener('click', function() {{ renderBoard(parseInt(this.dataset.idx)); }}); }}); renderBoard(totalMoves); </script> </body> </html>""" os.makedirs(os.path.dirname(filepath), exist_ok=True) with open(filepath, "w", encoding="utf-8") as f: f.write(html) def _build_move_list_html(san_moves: list[str], notation_errors: bool) -> str: """Build the interactive move list HTML with clickable move links.""" if not san_moves or notation_errors: return "(no moves)" parts = [] for i in range(0, len(san_moves), 2): num = i // 2 + 1 w = san_moves[i] # i+1 is the move index (1-indexed) for the white move w_link = f'<a class="move-link" data-idx="{i + 1}" href="#">{_e(w)}</a>' if i + 1 < len(san_moves): b = san_moves[i + 1] b_link = f'<a class="move-link" data-idx="{i + 2}" href="#">{_e(b)}</a>' parts.append(f'<span class="move-num">{num}.</span> {w_link} {b_link}') else: parts.append(f'<span class="move-num">{num}.</span> {w_link}') return " ".join(parts) def _compute_standings( rounds: list, engine_names: List[str], ) -> List[dict]: """Compute final standings from match results (grouped by round). Used only for Round Robin tournaments. Swiss tournaments must pass pre-computed standings via the swiss_standings parameter to ensure consistency with the Swiss handler's state. """ engines = { name: { "name": name, "points": 0.0, "wins": 0, "draws": 0, "losses": 0, } for name in engine_names } for round_matches in rounds: for match in round_matches: if match.engine_a not in engines or match.engine_b not in engines: continue engines[match.engine_a]["points"] += match.score_a engines[match.engine_b]["points"] += match.score_b for game in match.games: if game.result in ("1-0", "0-1"): winner = game.white if game.result == "1-0" else game.black loser = game.black if game.result == "1-0" else game.white _add_result(engines[winner], "win") _add_result(engines[loser], "loss") elif game.result == "1/2-1/2": _add_result(engines[game.white], "draw") _add_result(engines[game.black], "draw") return sorted(engines.values(), key=lambda e: -e["points"]) def _add_result(eng: dict, result_type: str): """Add a win/loss/draw to an engine's record (W/D/L counts only).""" if result_type == "win": eng["wins"] += 1 elif result_type == "draw": eng["draws"] += 1 elif result_type == "loss": eng["losses"] += 1