⚡ One Script, Infinite Positions — Desktop Chess Trainer That Gets Harder as You Improve

:chess_pawn: Chess Logic Trainer v2 — 9 Tactical Modes, Adaptive Difficulty, Zero Internet Required

A desktop chess brain-gym that generates infinite positions and gets harder as you improve.

9 training modes. Adaptive difficulty. Position editor. Timer mode. Full RU/EN bilingual interface.

So our second chess simulator came up with new modes and a bit of a delay because changes were being made. This one doesn’t teach you openings or endgames — it trains the thinking patterns behind every good move: counting material, spotting hanging pieces, evaluating trades, and finding the best move under pressure. One Python file, runs anywhere, no internet needed.


🧠 What This Actually Trains — All 9 Modes Explained

Every mode isolates a specific chess thinking skill. The app generates random legal positions, asks a question, validates the answer, then auto-advances to the next puzzle.

# Mode What It Builds Example Question
1 Material Advantage Counting piece values instantly “Who’s ahead and by how much?”
2 Is the Trade Favorable? Exchange evaluation before committing “After Rxe2 — good trade or bad?”
3 Material After Capture Sequence Calculating 1-2 move deep captures “After Bxd4 + recapture — who’s up?”
4 Find All Hanging Pieces Spotting undefended targets “Which pieces are attacked with no defender?”
5 Find All Defended Attacked Pieces Recognizing protected-but-pressured pieces “Which pieces are both attacked AND defended?”
6 Check or Immediate Threat Fast tactical scanning “Is there a check or forced threat right now?”
7 Outcome After a Specific Capture Precise single-move evaluation “After this exact capture, who benefits?”
8 Who Keeps Advantage After Sequence Multi-move judgment “After the exchange chain, who’s ahead?”
9 Best Move (1-2 ply depth) Practical move selection “What’s the strongest move here?”

:light_bulb: Piece values burned into every mode: Pawn = 1, Knight = 3, Bishop = 3, Rook = 5, Queen = 9, King = 0. The app shows this as a permanent reference while training.

How difficulty adapts: Above 85% accuracy → level increases (more pieces on the board, harder positions). Below 60% → level drops. Four difficulty tiers control how many pieces appear:

Level Pieces on Board Complexity
1 3-4 Simple king + 1-2 pieces
2 5-6 Small tactical scenarios
3 7-10 Mid-game complexity
4 10-18 Near-realistic positions
⚡ Install & Run — 3 Commands, Any OS

Requirements: Python 3.10+ (works with 3.10 through 3.14)

Install the two dependencies and run:

pip install python-chess PySide6
python chess_lesson2_app.py

:light_bulb: The #1 install fail: A different package called chess (not python-chess) sometimes squats the import name. If you get weird errors about missing attributes, fix it:

pip uninstall -y chess
pip install python-chess

The app checks for this on startup and shows a clear error message with fix instructions if it detects the wrong package.

Platform support:

OS Status Notes
Windows Works out of the box Python from python.org recommended
Linux Works May need sudo apt install python3-venv first
macOS Works Use python3 and pip3

Virtual environment recommended (keeps your system clean):

python -m venv chess-env
# Windows: chess-env\Scripts\activate
# Linux/Mac: source chess-env/bin/activate
pip install python-chess PySide6
python chess_lesson2_app.py
🖥️ Interface Tour — What You're Looking At

The interface splits into two panels. Left: interactive chessboard with file/rank coordinates and a piece reserve below for position editing. Right: all controls — language toggle, mode selector, difficulty display, demo box, prompt area, answer field, and live statistics.

Each mode starts with an animated demo on the board — arrows highlight the move, explanations appear in real-time. First time in any mode, a popup explains the concept with a sample answer before puzzles begin.

🎯 Features Most People Miss

Position Editor — Toggle the editor checkbox, then drag pieces or right-click to remove them. Use the piece reserve below the board to place new pieces. Build any position and test yourself on it.

Timer Mode — Toggle the timer checkbox. Starts at 20 seconds per puzzle. Get it right → time shrinks by 1 second. Get it wrong → time grows by 2 seconds. Adapts to your speed over the session.

Bilingual Interface — Switch between Russian and English mid-session via the dropdown. All prompts, demos, explanations, stats, and error messages switch instantly.

Progress Tracking — Stats persist between sessions in ~/.lesson2_chess_stats.json. Tracks per-mode: total solved, accuracy percentage, average solve time, and last 10 mistakes. Accuracy drives the adaptive difficulty — the app remembers where you left off.

Crash Recovery — If the app crashes (dependency issue, corrupt state), it writes a full traceback to ~/.lesson2_chess_crash.log with instructions. Send that file for debugging.

Answer Flexibility — The validator accepts multiple input formats:

  • Material: White +5, Black +2, Equal 0, or even total material like White 23
  • Yes/No: Да, Yes, Y, Нет, No, N
  • Squares: e4,h7 (comma-separated for multiple)
  • Moves: UCI format like e2e4
🔧 The Full Source — Single File, ~800 Lines

Copy, paste, save as chess_lesson2_app.py, and run.

#!/usr/bin/env python3
"""Chess Lesson 2 trainer (single-file app) using python-chess + PySide6."""

from __future__ import annotations

import json
import random
import sys
import time
import traceback
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional

import chess
from PySide6 import QtCore, QtGui, QtWidgets

PIECE_VALUES = {
    1: 1,  # pawn
    2: 3,  # knight
    3: 3,  # bishop
    4: 5,  # rook
    5: 9,  # queen
    6: 0,  # king
}

UNICODE = {
    "P": "♙", "N": "♘", "B": "♗", "R": "♖", "Q": "♕", "K": "♔",
    "p": "♟", "n": "♞", "b": "♝", "r": "♜", "q": "♛", "k": "♚",
}

MODE_LABELS_RU = [
    "1) Кто впереди по материалу",
    "2) Выгоден ли размен",
    "3) Итог материала после серии взятий",
    "4) Найти все висящие фигуры",
    "5) Найти все защищённые под атакой",
    "6) Шах или немедленная угроза",
    "7) Что произойдёт после конкретного взятия",
    "8) Кто останется с перевесом после последовательности",
    "9) Лучший ход (глубина 1-2 полухода)",
]

MODE_LABELS_EN = [
    "1) Material advantage",
    "2) Is the exchange favorable",
    "3) Material after a capture sequence",
    "4) Find all hanging pieces",
    "5) Find all defended attacked pieces",
    "6) Check or immediate threat",
    "7) What happens after a specific capture",
    "8) Who keeps advantage after short sequence",
    "9) Best move (depth 1-2 plies)",
]

MODE_DEMOS = {
    "1": {
        "demo": "Пример: у белых лишняя ладья (5 очков).",
        "answer": "White +5",
        "explain": "Сравниваем сумму фигур: если плюс у белых — White, у чёрных — Black, иначе Equal.",
    },
    "2": {
        "demo": "Пример: белая ладья берет ферзя и не теряется в ответ.",
        "answer": "Да",
        "explain": "Размен выгоден, если после взятия баланс не ухудшается для стороны, которая ходит.",
    },
    "3": {
        "demo": "Пример: белый слон бьёт коня, чёрная пешка отбивает слона.",
        "answer": "Equal",
        "explain": "Считаем итог материала после 1–2 полуходов серии взятий.",
    },
    "4": {
        "demo": "Пример: конь на e5 под боем и без защиты.",
        "answer": "e5",
        "explain": "Висящая фигура — под атакой соперника и без собственной защиты.",
    },
    "5": {
        "demo": "Пример: пешка d4 под атакой, но защищена ферзём.",
        "answer": "d4",
        "explain": "Нужны фигуры, которые одновременно атакуются и защищаются.",
    },
    "6": {
        "demo": "Пример: король под шахом или следующим ходом есть шах/взятие.",
        "answer": "Да",
        "explain": "Если уже есть шах или немедленная тактическая угроза — ответ Да.",
    },
    "7": {
        "demo": "Пример: после взятия белые выигрывают пешку.",
        "answer": "White",
        "explain": "Симулируем только указанное взятие и оцениваем материал после него.",
    },
    "8": {
        "demo": "Пример: взятие + ответное взятие, итог в пользу чёрных.",
        "answer": "Black",
        "explain": "Смотрим короткую последовательность и определяем, у кого перевес в конце.",
    },
    "9": {
        "demo": "Пример: лучший ход по мини-поиску — e2e4.",
        "answer": "e2e4",
        "explain": "Выбирается ход с лучшей оценкой на глубине 1–2 полухода.",
    },
}

MODE_DEMOS_EN = {
    "1": {"demo": "White has an extra rook (+5).", "answer": "White +5", "explain": "Compare material points."},
    "2": {"demo": "Rook captures queen and stays safe.", "answer": "Yes", "explain": "Exchange is favorable if balance does not worsen."},
    "3": {"demo": "Bishop captures knight, then recapture.", "answer": "Equal", "explain": "Count material after 1-2 plies."},
    "4": {"demo": "Knight on e5 is attacked and undefended.", "answer": "e5", "explain": "Hanging = attacked and not defended."},
    "5": {"demo": "Pawn d4 is attacked but defended.", "answer": "d4", "explain": "Piece is both attacked and defended."},
    "6": {"demo": "King is in check (or immediate threat exists).", "answer": "Yes", "explain": "Check/threat => Yes."},
    "7": {"demo": "After a specific capture, recalculate material.", "answer": "White", "explain": "Simulate only that capture."},
    "8": {"demo": "Short tactical sequence with recapture.", "answer": "Black", "explain": "Who is ahead at sequence end."},
    "9": {"demo": "Best move by shallow search: e2e4.", "answer": "e2e4", "explain": "Choose best evaluated move."},
}

MATERIAL_HINT = "Стоимость фигур: пешка=1, конь=3, слон=3, ладья=5, ферзь=9, король=0"
MATERIAL_HINT_EN = "Piece values: pawn=1, knight=3, bishop=3, rook=5, queen=9, king=0"



DEMO_SCENES = {
    "1": {
        "fen": "4k3/8/8/8/8/8/4R3/4K3 w - - 0 1",
        "moves": ["e2e6"],
        "explanations": ["Ладья активизируется: её ценность 5, поэтому даже без взятия видно, что материальный перевес у белых."],
        "explanations_en": ["The rook becomes active: it is worth 5 points, so White is materially ahead even without a capture."],
        "caption": "Материал: у белых лишняя ладья, значит White.",
        "caption_en": "Material: White has an extra rook, so White is ahead.",
    },
    "2": {
        "fen": "4k3/8/8/8/8/8/4q3/4R1K1 w - - 0 1",
        "moves": ["e1e2"],
        "explanations": ["Белая ладья забирает ферзя: это выгодный размен по материалу."],
        "explanations_en": ["White rook captures the queen: this is a favorable exchange by material."],
        "caption": "Размен: белая ладья бьёт ферзя e2 — размен выгоден.",
        "caption_en": "Exchange: white rook takes queen on e2 — favorable exchange.",
    },
    "3": {
        "fen": "4k3/8/8/8/3n4/2B5/8/4K3 w - - 0 1",
        "moves": ["c3d4"],
        "explanations": ["Слон бьёт коня: теперь считаем итог материала после короткой серии взятий."],
        "explanations_en": ["Bishop captures knight: now count final material after the short capture sequence."],
        "caption": "Серия взятий: после хода Cxd4 считаем итоговый материал.",
        "caption_en": "Capture sequence: after Bxd4, evaluate final material.",
    },
    "4": {
        "fen": "4k3/8/8/8/4n3/8/2B5/4K3 w - - 0 1",
        "moves": ["c2e4"],
        "explanations": ["Слон сразу забирает висящего коня e4: фигура была под боем и без защиты."],
        "explanations_en": ["Bishop immediately captures hanging knight on e4: it was attacked and undefended."],
        "caption": "Висящая фигура: конь e4 атакован и не защищён.",
        "caption_en": "Hanging piece: knight on e4 is attacked and undefended.",
    },
    "5": {
        "fen": "4k3/3q4/8/8/3p4/2B5/8/4K3 w - - 0 1",
        "moves": ["c3d4"],
        "explanations": ["Белый слон бьёт пешку d4, но идея режима в том, что пешка была защищена ферзём d7."],
        "explanations_en": ["White bishop captures pawn on d4, but the key idea is that this pawn was defended by the queen on d7."],
        "caption": "Защищённая под боем: пешка d4 атакована, но защищена.",
        "caption_en": "Defended under attack: pawn on d4 is attacked but defended.",
    },
    "6": {
        "fen": "4k3/8/8/8/8/8/4r3/4K3 w - - 0 1",
        "moves": ["e1f1"],
        "explanations": ["Белый король уходит из-под шаха: режим учит замечать шах и немедленные угрозы."],
        "explanations_en": ["White king steps out of check: this mode trains noticing checks and immediate threats."],
        "caption": "Угроза/шах: король белых уже под шахом от ладьи e2.",
        "caption_en": "Threat/check: white king is already in check from rook on e2.",
    },
    "7": {
        "fen": "4k3/8/8/8/8/8/4q3/4R1K1 w - - 0 1",
        "moves": ["e1e2"],
        "explanations": ["После конкретного взятия e1e2 пересчитываем материал и определяем, кто впереди."],
        "explanations_en": ["After the specific capture e1e2, recalculate material and determine who is ahead."],
        "caption": "После конкретного взятия e1e2 пересчитываем материал.",
        "caption_en": "After specific capture e1e2, recalculate material.",
    },
    "8": {
        "fen": "4k3/8/8/8/3p4/3B4/8/4K3 w - - 0 1",
        "moves": ["d3d4"],
        "explanations": ["Слон бьёт пешку: затем оцениваем, кто останется с перевесом после короткой последовательности."],
        "explanations_en": ["Bishop captures pawn: then evaluate who keeps the advantage after the short sequence."],
        "caption": "Короткая последовательность: показываем взятие и смотрим, кто в плюсе.",
        "caption_en": "Short sequence: show the capture and see who stays ahead.",
    },
    "9": {
        "fen": "4k3/8/8/8/8/8/4p3/4R1K1 w - - 0 1",
        "moves": ["e1e2"],
        "explanations": ["Ладья забирает фигуру e2: это пример лучшего хода на малой глубине анализа."],
        "explanations_en": ["Rook captures piece on e2: this is an example of the best move in shallow search."],
        "caption": "Лучший ход (пример): белые забирают фигуру ходом e1e2.",
        "caption_en": "Best move (example): White captures on e2 with the rook.",
    },
}

LEVEL_RANGES = {
    1: (3, 4),
    2: (5, 6),
    3: (7, 10),
    4: (10, 18),
}

STAT_PATH = Path.home() / ".lesson2_chess_stats.json"
CRASH_LOG_PATH = Path.home() / ".lesson2_chess_crash.log"
DEMO_STEP_MS = 2200
DEMO_FINISH_MS = 1500


def validate_python_chess_module() -> None:
    """Fail fast with a clear message when a wrong `chess` package/module is installed."""
    required_attrs = [
        "PAWN", "KNIGHT", "BISHOP", "ROOK", "QUEEN", "KING",
        "Board", "Move", "SQUARES",
    ]
    missing = [attr for attr in required_attrs if not hasattr(chess, attr)]
    if missing:
        module_path = getattr(chess, "__file__", "<unknown>")
        raise RuntimeError(
            "Обнаружен несовместимый модуль `chess` (не python-chess).\n"
            f"Путь модуля: {module_path}\n"
            f"Отсутствуют атрибуты: {', '.join(missing)}\n\n"
            "Исправление:\n"
            "1) python -m pip uninstall -y chess\n"
            "2) python -m pip install --upgrade pip\n"
            "3) python -m pip install --index-url https://pypi.org/simple python-chess\n"
            "4) Если зеркало не содержит пакет: python -m pip install git+https://github.com/niklasf/python-chess.git\n"
            "5) Убедитесь, что рядом с файлом нет локального `chess.py`"
        )


@dataclass
class Task:
    mode: str
    prompt: str
    answer: str
    board: chess.Board
    extra: Dict[str, str] = field(default_factory=dict)


class PositionGenerator:
    def __init__(self) -> None:
        self.history_set = set()

    def _remember(self, fen: str) -> None:
        self.history_set.add(fen)

    def random_legal(self, level: int) -> chess.Board:
        min_p, max_p = LEVEL_RANGES[level]
        while True:
            board = chess.Board(None)
            wk, bk = random.sample(list(chess.SQUARES), 2)
            if chess.square_distance(wk, bk) <= 1:
                continue
            board.set_piece_at(wk, chess.Piece(chess.KING, chess.WHITE))
            board.set_piece_at(bk, chess.Piece(chess.KING, chess.BLACK))

            total = random.randint(min_p, max_p)
            others = max(0, total - 2)
            for _ in range(others):
                free = [s for s in chess.SQUARES if board.piece_at(s) is None]
                if not free:
                    break
                sq = random.choice(free)
                color = random.choice([chess.WHITE, chess.BLACK])
                ptype = random.choice([
                    chess.PAWN, chess.PAWN, chess.PAWN,
                    chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN,
                ])
                rank = chess.square_rank(sq)
                if ptype == chess.PAWN and rank in (0, 7):
                    continue
                board.set_piece_at(sq, chess.Piece(ptype, color))

            board.turn = random.choice([chess.WHITE, chess.BLACK])
            board.clear_stack()
            if not board.is_valid():
                continue
            fen = board.fen()
            if fen in self.history_set:
                continue
            self._remember(fen)
            return board


class Analyzer:
    @staticmethod
    def material(board: chess.Board) -> int:
        score = 0
        for sq, p in board.piece_map().items():
            v = PIECE_VALUES[p.piece_type]
            score += v if p.color == chess.WHITE else -v
        return score

    @staticmethod
    def balance_label(board: chess.Board) -> str:
        m = Analyzer.material(board)
        if m > 0:
            return "White"
        if m < 0:
            return "Black"
        return "Equal"

    @staticmethod
    def balance_with_points(board: chess.Board) -> str:
        m = Analyzer.material(board)
        if m > 0:
            return f"White +{m}"
        if m < 0:
            return f"Black +{abs(m)}"
        return "Equal 0"

    @staticmethod
    def material_totals(board: chess.Board) -> tuple[int, int]:
        white = 0
        black = 0
        for p in board.piece_map().values():
            v = PIECE_VALUES[p.piece_type]
            if p.color == chess.WHITE:
                white += v
            else:
                black += v
        return white, black

    @staticmethod
    def attackers(board: chess.Board, square: chess.Square, by_white: bool) -> List[chess.Square]:
        return list(board.attackers(chess.WHITE if by_white else chess.BLACK, square))

    @staticmethod
    def hanging_squares(board: chess.Board) -> List[str]:
        out = []
        for sq, p in board.piece_map().items():
            attackers = board.attackers(not p.color, sq)
            defenders = board.attackers(p.color, sq)
            if attackers and not defenders:
                out.append(chess.square_name(sq))
        return sorted(out)

    @staticmethod
    def defended_under_attack(board: chess.Board) -> List[str]:
        out = []
        for sq, p in board.piece_map().items():
            if board.attackers(not p.color, sq) and board.attackers(p.color, sq):
                out.append(chess.square_name(sq))
        return sorted(out)

    @staticmethod
    def capture_delta(board: chess.Board, move: chess.Move) -> int:
        before = Analyzer.material(board)
        b2 = board.copy(stack=False)
        b2.push(move)
        return Analyzer.material(b2) - before

    @staticmethod
    def best_move(board: chess.Board, depth: int = 2) -> Optional[chess.Move]:
        def evaluate(b: chess.Board) -> int:
            score = Analyzer.material(b)
            if b.is_check():
                score += -1 if b.turn == chess.WHITE else 1
            return score

        def minimax(b: chess.Board, d: int) -> int:
            if d == 0 or b.is_game_over():
                return evaluate(b)
            vals = []
            for mv in b.legal_moves:
                c = b.copy(stack=False)
                c.push(mv)
                vals.append(minimax(c, d - 1))
            if not vals:
                return evaluate(b)
            return max(vals) if b.turn == chess.WHITE else min(vals)

        best, best_score = None, None
        for mv in board.legal_moves:
            c = board.copy(stack=False)
            c.push(mv)
            sc = minimax(c, max(0, depth - 1))
            if best is None:
                best, best_score = mv, sc
            else:
                if board.turn == chess.WHITE and sc > best_score:
                    best, best_score = mv, sc
                if board.turn == chess.BLACK and sc < best_score:
                    best, best_score = mv, sc
        return best


class TaskFactory:
    def __init__(self, gen: PositionGenerator) -> None:
        self.gen = gen

    def make(self, mode: str, level: int, lang: str = "ru") -> Task:
        while True:
            board = self.gen.random_legal(level)
            task = self._build(mode, board, lang)
            if task:
                return task

    def _build(self, mode: str, board: chess.Board, lang: str) -> Optional[Task]:
        captures = [m for m in board.legal_moves if board.is_capture(m)]
        if mode.startswith("1"):
            prompt = (
                "Кто впереди по материалу и на сколько очков? (например: White +3 / Black +2 / Equal 0). Также можно ввести общий материал стороны: White 23"
                if lang == "ru"
                else "Who is ahead by material and by how many points? (e.g. White +3 / Black +2 / Equal 0). You can also enter side total: White 23"
            )
            return Task(
                mode,
                prompt,
                Analyzer.balance_with_points(board),
                board,
            )
        if mode.startswith("2"):
            if not captures:
                return None
            mv = random.choice(captures)
            delta = Analyzer.capture_delta(board, mv)
            ans = ("Да" if lang == "ru" else "Yes") if (delta >= 0 if board.turn == chess.WHITE else delta <= 0) else ("Нет" if lang == "ru" else "No")
            prompt = f"Выгоден ли размен после {mv.uci()}? (Да/Нет)" if lang == "ru" else f"Is exchange favorable after {mv.uci()}? (Yes/No)"
            return Task(mode, prompt, ans, board)
        if mode.startswith("3"):
            if not captures:
                return None
            mv = random.choice(captures)
            b2 = board.copy(stack=False)
            b2.push(mv)
            recaps = [m for m in b2.legal_moves if b2.is_capture(m)]
            if recaps:
                b2.push(random.choice(recaps))
            prompt = (
                f"Материал после серии: {mv.uci()} + возможный ответный захват? (White/Black/Equal)"
                if lang == "ru"
                else f"Material after sequence: {mv.uci()} + possible recapture? (White/Black/Equal)"
            )
            return Task(mode, prompt, Analyzer.balance_label(b2), board)
        if mode.startswith("4"):
            hs = Analyzer.hanging_squares(board)
            if not hs:
                return None
            prompt = "Найдите все висящие фигуры (через запятую, напр. e4,h7)" if lang == "ru" else "Find all hanging pieces (comma-separated, e.g. e4,h7)"
            return Task(mode, prompt, ",".join(hs), board)
        if mode.startswith("5"):
            ds = Analyzer.defended_under_attack(board)
            if not ds:
                return None
            prompt = "Найдите фигуры под атакой, но с защитой (через запятую)" if lang == "ru" else "Find attacked but defended pieces (comma-separated)"
            return Task(mode, prompt, ",".join(ds), board)
        if mode.startswith("6"):
            in_check = board.is_check()
            threat = False
            for mv in board.legal_moves:
                c = board.copy(stack=False)
                c.push(mv)
                if c.is_check() or any(c.is_capture(x) for x in c.legal_moves):
                    threat = True
                    break
            ans = ("Да" if lang == "ru" else "Yes") if (in_check or threat) else ("Нет" if lang == "ru" else "No")
            prompt = "Есть ли шах или немедленная угроза? (Да/Нет)" if lang == "ru" else "Is there check or immediate threat? (Yes/No)"
            return Task(mode, prompt, ans, board)
        if mode.startswith("7"):
            if not captures:
                return None
            mv = random.choice(captures)
            b2 = board.copy(stack=False)
            b2.push(mv)
            prompt = f"Что будет по материалу после взятия {mv.uci()}? (White/Black/Equal)" if lang == "ru" else f"Material outcome after capture {mv.uci()}? (White/Black/Equal)"
            return Task(mode, prompt, Analyzer.balance_label(b2), board)
        if mode.startswith("8"):
            if not captures:
                return None
            mv = random.choice(captures)
            b2 = board.copy(stack=False)
            b2.push(mv)
            recaps = [m for m in b2.legal_moves if b2.is_capture(m)]
            if recaps:
                b2.push(random.choice(recaps))
            prompt = "Кто останется с перевесом после короткой последовательности? (White/Black/Equal)" if lang == "ru" else "Who keeps advantage after short sequence? (White/Black/Equal)"
            return Task(mode, prompt, Analyzer.balance_label(b2), board)
        if mode.startswith("9"):
            bm = Analyzer.best_move(board, depth=2)
            if bm is None:
                return None
            prompt = "Лучший ход (в формате UCI, например e2e4):" if lang == "ru" else "Best move (UCI format, e.g. e2e4):"
            return Task(mode, prompt, bm.uci(), board)
        return None


class BoardWidget(QtWidgets.QWidget):
    board_changed = QtCore.Signal()

    def __init__(self, board: chess.Board) -> None:
        super().__init__()
        self.board = board
        self.selected_square: Optional[chess.Square] = None
        self.selected_reserve: Optional[chess.Piece] = None
        self.edit_mode = False
        self.cells: Dict[chess.Square, QtWidgets.QPushButton] = {}
        self.square_to_button: Dict[chess.Square, QtWidgets.QPushButton] = {}
        self.square_buttons: List[QtWidgets.QPushButton] = []
        self.reserve_buttons: List[QtWidgets.QPushButton] = []
        self.coord_labels: List[QtWidgets.QLabel] = []
        self.demo_highlight_squares: set[chess.Square] = set()
        self.demo_arrow_text: str = ""
        self.white_reserve_label = QtWidgets.QLabel("Белые:")
        self.black_reserve_label = QtWidgets.QLabel("Чёрные:")
        self.grid = QtWidgets.QGridLayout()

        layout = QtWidgets.QVBoxLayout(self)
        self.grid.setSpacing(0)
        layout.addLayout(self.grid, 1)

        files = "abcdefgh"
        for i, file_char in enumerate(files):
            top = QtWidgets.QLabel(file_char)
            top.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
            self.grid.addWidget(top, 0, i + 1)
            self.coord_labels.append(top)

            bottom = QtWidgets.QLabel(file_char)
            bottom.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
            self.grid.addWidget(bottom, 9, i + 1)
            self.coord_labels.append(bottom)

            rank = str(8 - i)
            left = QtWidgets.QLabel(rank)
            left.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
            self.grid.addWidget(left, i + 1, 0)
            self.coord_labels.append(left)

            right = QtWidgets.QLabel(rank)
            right.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
            self.grid.addWidget(right, i + 1, 9)
            self.coord_labels.append(right)

        for r in range(8):
            for f in range(8):
                sq = chess.square(f, 7 - r)
                btn = QtWidgets.QPushButton()
                btn.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
                btn.clicked.connect(lambda _=False, s=sq: self.handle_square_click(s))
                self.cells[sq] = btn
                self.square_to_button[sq] = btn
                self.square_buttons.append(btn)
                color = "#f7e7ce" if (r + f) % 2 == 0 else "#8c5a3c"
                btn.setProperty("cell_color", color)
                btn.setProperty("square_index", sq)
                self.grid.addWidget(btn, r + 1, f + 1)

        for i in range(1, 9):
            self.grid.setRowStretch(i, 1)
            self.grid.setColumnStretch(i, 1)

        reserve_box = QtWidgets.QHBoxLayout()
        layout.addLayout(reserve_box)
        reserve_box.addWidget(self.white_reserve_label)
        for symbol in "PNBRQ":
            b = QtWidgets.QPushButton(UNICODE[symbol])
            b.setFixedSize(38, 38)
            b.clicked.connect(lambda _=False, s=symbol: self.select_reserve(s, chess.WHITE))
            self.reserve_buttons.append(b)
            reserve_box.addWidget(b)
        reserve_box.addSpacing(20)
        reserve_box.addWidget(self.black_reserve_label)
        for symbol in "pnbrq":
            b = QtWidgets.QPushButton(UNICODE[symbol])
            b.setFixedSize(38, 38)
            b.clicked.connect(lambda _=False, s=symbol: self.select_reserve(s, chess.BLACK))
            self.reserve_buttons.append(b)
            reserve_box.addWidget(b)

        self.setToolTip("ЛКМ: выбор/ход/добавление. ПКМ по клетке: убрать фигуру (кроме королей).")
        self._resize_cells()
        self.refresh()

    def set_language(self, lang: str) -> None:
        if lang == "ru":
            self.white_reserve_label.setText("Белые:")
            self.black_reserve_label.setText("Чёрные:")
            self.setToolTip("ЛКМ: выбор/ход/добавление. ПКМ по клетке: убрать фигуру (кроме королей).")
        else:
            self.white_reserve_label.setText("White:")
            self.black_reserve_label.setText("Black:")
            self.setToolTip("LMB: select/move/add. RMB on square: remove piece (except kings).")

    def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
        super().resizeEvent(event)
        self._resize_cells()

    def _resize_cells(self) -> None:
        side = max(240, min(self.width(), self.height() - 90))
        cell = max(28, side // 10)
        piece_font = max(18, int(cell * 0.55))
        for btn in self.square_buttons:
            btn.setProperty("piece_font", piece_font)
            self._apply_cell_style(btn)

        coord_font = max(10, int(cell * 0.22))
        for label in self.coord_labels:
            label.setStyleSheet(f"font-size: {coord_font}px; font-weight: 600; color: #2f2f2f;")

        reserve_size = max(34, int(cell * 0.55))
        reserve_font = max(16, int(reserve_size * 0.55))
        for btn in self.reserve_buttons:
            btn.setFixedSize(reserve_size, reserve_size)
            btn.setStyleSheet(f"font-size: {reserve_font}px;")

    def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
        super().mousePressEvent(event)

    def contextMenuEvent(self, event: QtGui.QContextMenuEvent) -> None:
        pos = event.pos()
        child = self.childAt(pos)
        for sq, btn in self.cells.items():
            if btn is child:
                p = self.board.piece_at(sq)
                if p and p.piece_type != chess.KING:
                    self.board.remove_piece_at(sq)
                    self.refresh()
                    self.board_changed.emit()
                return

    def set_board(self, board: chess.Board) -> None:
        self.board = board
        self.selected_square = None
        self.selected_reserve = None
        self.refresh()

    def select_reserve(self, symbol: str, color: chess.Color) -> None:
        p = chess.Piece.from_symbol(symbol)
        self.selected_reserve = chess.Piece(p.piece_type, color)
        self.selected_square = None

    def handle_square_click(self, sq: chess.Square) -> None:
        if self.selected_reserve and self.edit_mode:
            if self.board.piece_at(sq) is None:
                if self.selected_reserve.piece_type == chess.PAWN and chess.square_rank(sq) in (0, 7):
                    return
                self.board.set_piece_at(sq, self.selected_reserve)
                if self.board.is_valid():
                    self.selected_reserve = None
                    self.refresh()
                    self.board_changed.emit()
                else:
                    self.board.remove_piece_at(sq)
            return

        if self.selected_square is None:
            p = self.board.piece_at(sq)
            if p is None:
                return
            if not self.edit_mode and p.color != self.board.turn:
                return
            self.selected_square = sq
            return

        if self.edit_mode:
            piece = self.board.piece_at(self.selected_square)
            if piece:
                self.board.remove_piece_at(self.selected_square)
                self.board.set_piece_at(sq, piece)
                if not self.board.is_valid():
                    self.board.remove_piece_at(sq)
                    self.board.set_piece_at(self.selected_square, piece)
            self.selected_square = None
            self.refresh()
            self.board_changed.emit()
            return

        move = chess.Move(self.selected_square, sq)
        if move in self.board.legal_moves:
            self.board.push(move)
            self.board_changed.emit()
        self.selected_square = None
        self.refresh()

    def refresh(self) -> None:
        for sq, btn in self.cells.items():
            p = self.board.piece_at(sq)
            btn.setText(UNICODE[p.symbol()] if p else "")
            btn.setProperty("piece_color", "white" if p and p.color == chess.WHITE else ("black" if p else "none"))
            self._apply_cell_style(btn)

    def set_demo_hint(self, from_sq: chess.Square, to_sq: chess.Square) -> None:
        self.demo_highlight_squares = {from_sq, to_sq}
        self.demo_arrow_text = f"{chess.square_name(from_sq)} ➜ {chess.square_name(to_sq)}"
        self.refresh()

    def clear_demo_hint(self) -> None:
        self.demo_highlight_squares.clear()
        self.demo_arrow_text = ""
        self.refresh()

    def _apply_cell_style(self, btn: QtWidgets.QPushButton) -> None:
        color = btn.property("cell_color") or "#f0d9b5"
        piece_font = int(btn.property("piece_font") or 28)
        piece_color = btn.property("piece_color") or "none"
        square_index = btn.property("square_index")
        is_demo_square = square_index in self.demo_highlight_squares
        if piece_color == "white":
            fg = "#ffffff"
        elif piece_color == "black":
            fg = "#121212"
        else:
            fg = "transparent"

        border = "3px solid #ffd400" if is_demo_square else "1px solid rgba(0,0,0,0.15)"
        glow = "background: qradialgradient(cx:0.5, cy:0.5, radius:1, fx:0.5, fy:0.5, stop:0 #ffe066, stop:1 {color});" if is_demo_square else f"background: {color};"

        btn.setStyleSheet(
            "QPushButton {"
            f"font-size: {piece_font}px;"
            f"{glow.format(color=color)}"
            f"color: {fg};"
            f"border: {border};"
            "font-weight: 700;"
            "}"
            "QPushButton:hover { border: 2px solid #2f80ed; }"
        )


class ChessTrainerWindow(QtWidgets.QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        self.setWindowTitle("Chess Lesson 2 Trainer")
        self.resize(1000, 760)

        self.stats = self.load_stats()
        self.lang = self.stats.get("lang", "ru")
        self.level = self.stats.get("level", 1)
        self.current_task: Optional[Task] = None
        self.current_start = time.time()
        self.timer_mode = False
        self.time_limit = 20
        self.timer = QtCore.QTimer(self)
        self.timer.timeout.connect(self.tick)
        self.demo_shown_modes: set[str] = set()
        self.demo_timer = QtCore.QTimer(self)
        self.demo_timer.timeout.connect(self._advance_demo_move)
        self.demo_moves_queue: List[str] = []
        self.demo_explanations: List[str] = []
        self.demo_step_index = 0
        self.pending_mode_after_demo: Optional[str] = None

        self.generator = PositionGenerator()
        self.factory = TaskFactory(self.generator)

        central = QtWidgets.QWidget()
        self.setCentralWidget(central)
        h = QtWidgets.QHBoxLayout(central)

        self.board_widget = BoardWidget(chess.Board())
        self.board_widget.board_changed.connect(self.on_board_changed)
        h.addWidget(self.board_widget)

        right = QtWidgets.QVBoxLayout()
        h.addLayout(right)

        self.lang_combo = QtWidgets.QComboBox()
        self.lang_combo.addItems(["Русский", "English"])
        self.lang_combo.setCurrentIndex(0 if self.lang == "ru" else 1)
        self.lang_combo.currentIndexChanged.connect(self.on_language_changed)
        right.addWidget(self.lang_combo)

        self.mode_combo = QtWidgets.QComboBox()
        self.mode_combo.addItems(MODE_LABELS_RU if self.lang == "ru" else MODE_LABELS_EN)
        self.mode_combo.currentTextChanged.connect(self.update_demo_box)
        self.mode_combo.currentIndexChanged.connect(self.on_mode_selected)
        right.addWidget(self.mode_combo)

        self.level_label = QtWidgets.QLabel()
        right.addWidget(self.level_label)

        self.timer_check = QtWidgets.QCheckBox()
        self.timer_check.toggled.connect(self.on_timer_toggle)
        right.addWidget(self.timer_check)

        self.edit_check = QtWidgets.QCheckBox()
        self.edit_check.toggled.connect(self.on_edit_toggle)
        right.addWidget(self.edit_check)

        self.demo_box = QtWidgets.QTextEdit()
        self.demo_box.setReadOnly(True)
        self.demo_box.setMinimumHeight(140)
        right.addWidget(self.demo_box)

        self.prompt = QtWidgets.QLabel()
        self.prompt.setWordWrap(True)
        right.addWidget(self.prompt)

        self.material_hint = QtWidgets.QLabel()
        self.material_hint.setWordWrap(True)
        self.material_hint.setStyleSheet("font-weight: 600;")
        right.addWidget(self.material_hint)

        self.answer = QtWidgets.QLineEdit()
        self.answer.setPlaceholderText("")
        right.addWidget(self.answer)

        btns = QtWidgets.QHBoxLayout()
        right.addLayout(btns)
        self.new_btn = QtWidgets.QPushButton()
        self.new_btn.clicked.connect(self.new_task)
        btns.addWidget(self.new_btn)

        self.demo_btn = QtWidgets.QPushButton()
        self.demo_btn.clicked.connect(self.play_demo_for_current_mode)
        btns.addWidget(self.demo_btn)

        self.check_btn = QtWidgets.QPushButton()
        self.check_btn.clicked.connect(self.check_answer)
        btns.addWidget(self.check_btn)

        self.timer_label = QtWidgets.QLabel("")
        right.addWidget(self.timer_label)

        self.result = QtWidgets.QLabel("")
        self.result.setWordWrap(True)
        right.addWidget(self.result)

        self.stats_box = QtWidgets.QTextEdit()
        self.stats_box.setReadOnly(True)
        right.addWidget(self.stats_box, 1)

        self.update_ui_meta()
        self.apply_language_texts()
        self.update_demo_box()
        self.refresh_stats()
        QtCore.QTimer.singleShot(0, self.new_task)

    def on_mode_selected(self) -> None:
        self.new_task()

    def on_language_changed(self) -> None:
        old_key = self._mode_key(self.mode_combo.currentText())
        self.lang = "ru" if self.lang_combo.currentIndex() == 0 else "en"
        labels = MODE_LABELS_RU if self.lang == "ru" else MODE_LABELS_EN
        self.mode_combo.blockSignals(True)
        self.mode_combo.clear()
        self.mode_combo.addItems(labels)
        idx = max(0, min(len(labels) - 1, int(old_key) - 1 if old_key.isdigit() else 0))
        self.mode_combo.setCurrentIndex(idx)
        self.mode_combo.blockSignals(False)
        self.apply_language_texts()
        self.update_demo_box()
        self.refresh_stats()

    def apply_language_texts(self) -> None:
        self.board_widget.set_language(self.lang)
        if self.lang == "ru":
            self.timer_check.setText("Режим с таймером")
            self.edit_check.setText("Редактор позиции (добавлять/убирать фигуры)")
            self.prompt.setText("Нажмите «Новая задача»")
            self.material_hint.setText(MATERIAL_HINT)
            self.answer.setPlaceholderText("Введите ответ")
            self.new_btn.setText("Новая задача")
            self.demo_btn.setText("Демо на доске")
            self.check_btn.setText("Проверить")
        else:
            self.timer_check.setText("Timer mode")
            self.edit_check.setText("Position editor (add/remove/move pieces)")
            self.prompt.setText("Press 'New task'")
            self.material_hint.setText(MATERIAL_HINT_EN)
            self.answer.setPlaceholderText("Enter answer")
            self.new_btn.setText("New task")
            self.demo_btn.setText("Board demo")
            self.check_btn.setText("Check")

    def _mode_key(self, mode_text: str) -> str:
        return mode_text.split(")", 1)[0].strip()

    def update_demo_box(self) -> None:
        mode = self.mode_combo.currentText()
        demos = MODE_DEMOS if self.lang == "ru" else MODE_DEMOS_EN
        info = demos.get(self._mode_key(mode), {})
        hint = MATERIAL_HINT if self.lang == "ru" else MATERIAL_HINT_EN
        title = "Демо режима" if self.lang == "ru" else "Mode demo"
        example = "Пример" if self.lang == "ru" else "Example"
        sample = "Пример ответа" if self.lang == "ru" else "Sample answer"
        explain = "Объяснение" if self.lang == "ru" else "Explanation"
        text = (
            f"{title}: {mode}\n"
            f"{example}: {info.get('demo', '—')}\n"
            f"{sample}: {info.get('answer', '—')}\n"
            f"{explain}: {info.get('explain', '—')}\n"
            f"{hint}"
        )
        self.demo_box.setText(text)

    def show_mode_demo_dialog(self, mode: str) -> None:
        key = self._mode_key(mode)
        if key in self.demo_shown_modes:
            return
        demos = MODE_DEMOS if self.lang == "ru" else MODE_DEMOS_EN
        info = demos.get(key, {})
        title = "Демонстрация перед стартом" if self.lang == "ru" else "Demo before start"
        why = "Почему так" if self.lang == "ru" else "Why"
        example = "Пример" if self.lang == "ru" else "Example"
        sample = "Пример ответа" if self.lang == "ru" else "Sample answer"
        QtWidgets.QMessageBox.information(
            self,
            title,
            f"{mode}\n\n"
            f"{example}: {info.get('demo', '—')}\n"
            f"{sample}: {info.get('answer', '—')}\n\n"
            f"{why}: {info.get('explain', '—')}",
        )
        self.demo_shown_modes.add(key)

    def play_demo_for_current_mode(self) -> None:
        mode = self.mode_combo.currentText()
        key = self._mode_key(mode)
        scene = DEMO_SCENES.get(key)
        if not scene:
            self.result.setText("Для этого режима нет отдельной анимации демо." if self.lang == "ru" else "No dedicated demo animation for this mode.")
            return

        board = chess.Board(scene["fen"])
        self.board_widget.set_board(board)
        self.board_widget.clear_demo_hint()
        self.demo_moves_queue = list(scene.get("moves", []))
        if self.lang == "ru":
            self.demo_explanations = list(scene.get("explanations", []))
            caption = scene.get("caption", "")
        else:
            self.demo_explanations = list(scene.get("explanations_en", scene.get("explanations", [])))
            caption = scene.get("caption_en", scene.get("caption", ""))
        self.demo_step_index = 0
        self.result.setText(f"🎬 {'Демо' if self.lang == 'ru' else 'Demo'}: {caption}")

        self.new_btn.setEnabled(False)
        self.check_btn.setEnabled(False)
        self.demo_btn.setEnabled(False)
        self.answer.setEnabled(False)

        if self.demo_moves_queue:
            self.demo_timer.start(DEMO_STEP_MS)
        else:
            QtCore.QTimer.singleShot(DEMO_FINISH_MS, self._finish_demo_playback)

    def _advance_demo_move(self) -> None:
        if not self.demo_moves_queue:
            self.demo_timer.stop()
            self._finish_demo_playback()
            return

        uci = self.demo_moves_queue.pop(0)
        mv = chess.Move.from_uci(uci)
        self.board_widget.set_demo_hint(mv.from_square, mv.to_square)
        if mv in self.board_widget.board.legal_moves:
            self.board_widget.board.push(mv)
            self.board_widget.refresh()
            explanation = (
                self.demo_explanations[self.demo_step_index]
                if self.demo_step_index < len(self.demo_explanations)
                else ("Объяснение шага отсутствует." if self.lang == "ru" else "Step explanation is not provided.")
            )
            self.demo_step_index += 1
            if self.lang == "ru":
                self.result.setText(
                    f"🎬 Демо: ход {chess.square_name(mv.from_square)} ➜ {chess.square_name(mv.to_square)}\n"
                    f"🧠 {explanation}"
                )
            else:
                self.result.setText(
                    f"🎬 Demo: move {chess.square_name(mv.from_square)} ➜ {chess.square_name(mv.to_square)}\n"
                    f"🧠 {explanation}"
                )

        if not self.demo_moves_queue:
            self.demo_timer.stop()
            QtCore.QTimer.singleShot(DEMO_FINISH_MS, self._finish_demo_playback)

    def _finish_demo_playback(self) -> None:
        self.board_widget.clear_demo_hint()
        self.new_btn.setEnabled(True)
        self.check_btn.setEnabled(True)
        self.demo_btn.setEnabled(True)
        self.answer.setEnabled(True)
        if self.pending_mode_after_demo is not None:
            mode = self.pending_mode_after_demo
            self.pending_mode_after_demo = None
            self._start_task(mode)

    def on_board_changed(self) -> None:
        if not self.edit_check.isChecked() and self.current_task:
            self.result.setText("Позиция изменена ходом на доске." if self.lang == "ru" else "Position changed by a board move.")

    def on_edit_toggle(self, checked: bool) -> None:
        self.board_widget.edit_mode = checked

    def on_timer_toggle(self, checked: bool) -> None:
        self.timer_mode = checked

    def normalize(self, text: str) -> str:
        return text.strip().lower().replace(" ", "")

    def _canonical_yes_no(self, text: str) -> Optional[bool]:
        t = self.normalize(text)
        yes = {"да", "yes", "y"}
        no = {"нет", "no", "n"}
        if t in yes:
            return True
        if t in no:
            return False
        return None

    def _canonical_side(self, text: str) -> Optional[str]:
        t = self.normalize(text)
        mapping = {
            "white": "white",
            "белые": "white",
            "белый": "white",
            "black": "black",
            "черные": "black",
            "чёрные": "black",
            "черный": "black",
            "чёрный": "black",
            "equal": "equal",
            "равно": "equal",
            "ничья": "equal",
        }
        return mapping.get(t)

    def _parse_side_points(self, text: str) -> tuple[Optional[str], Optional[int]]:
        raw = text.strip().lower()
        compact = self.normalize(text)

        side = None
        for token in ["white", "белые", "белый", "black", "черные", "чёрные", "черный", "чёрный", "equal", "равно", "ничья"]:
            if token in raw or token in compact:
                side = self._canonical_side(token)
                break

        digits = "".join(ch for ch in compact if ch.isdigit())
        points = int(digits) if digits else None
        return side, points

    def _is_correct_answer(self, mode: str, user_text: str, true_text: str, board: Optional[chess.Board] = None) -> bool:
        user = self.normalize(user_text)
        true = self.normalize(true_text)
        if user == true:
            return True

        if mode.startswith("1"):
            expected_side, expected_points = self._parse_side_points(true_text)
            user_side, user_points = self._parse_side_points(user_text)
            if expected_side is None or user_side is None:
                return False
            if user_side != expected_side:
                return False
            if user_points is None or user_points == expected_points:
                return True
            if board is not None:
                w_total, b_total = Analyzer.material_totals(board)
                target_total = w_total if user_side == "white" else b_total if user_side == "black" else None
                if user_side == "equal":
                    return user_points in {0, abs(w_total - b_total)}
                return target_total is not None and user_points == target_total
            return False

        if mode.startswith("2") or mode.startswith("6"):
            expected = self._canonical_yes_no(true_text)
            got = self._canonical_yes_no(user_text)
            return expected is not None and got is not None and expected == got

        if mode.startswith("3") or mode.startswith("7") or mode.startswith("8"):
            expected = self._canonical_side(true_text)
            got = self._canonical_side(user_text)
            return expected is not None and got is not None and expected == got

        return False

    def load_stats(self) -> Dict:
        if STAT_PATH.exists():
            try:
                return json.loads(STAT_PATH.read_text(encoding="utf-8"))
            except Exception:
                pass
        return {
            "level": 1,
            "solved": {},
            "correct": {},
            "times": {},
            "errors": [],
        }

    def save_stats(self) -> None:
        self.stats["level"] = self.level
        self.stats["lang"] = self.lang
        STAT_PATH.write_text(json.dumps(self.stats, ensure_ascii=False, indent=2), encoding="utf-8")

    def update_ui_meta(self) -> None:
        self.level_label.setText(f"Текущий уровень: {self.level}" if self.lang == "ru" else f"Current level: {self.level}")

    def refresh_stats(self) -> None:
        solved = self.stats.get("solved", {})
        correct = self.stats.get("correct", {})
        times = self.stats.get("times", {})
        lines = ["Статистика:" if self.lang == "ru" else "Statistics:"]
        labels = MODE_LABELS_RU if self.lang == "ru" else MODE_LABELS_EN
        for mode in labels:
            s = solved.get(mode, 0)
            c = correct.get(mode, 0)
            pct = round((c / s) * 100, 1) if s else 0
            avg_t = round(sum(times.get(mode, [])) / len(times.get(mode, [])), 2) if times.get(mode) else 0
            if self.lang == "ru":
                lines.append(f"- {mode}: решено={s}, точность={pct}%, ср.время={avg_t}с")
            else:
                lines.append(f"- {mode}: solved={s}, accuracy={pct}%, avg_time={avg_t}s")
        err = self.stats.get("errors", [])[-10:]
        lines.append("\nПоследние ошибки:" if self.lang == "ru" else "\nRecent mistakes:")
        lines.extend([f"  • {e}" for e in err] or (["  • нет"] if self.lang == "ru" else ["  • none"]))
        self.stats_box.setText("\n".join(lines))

    def _start_task(self, mode: str) -> None:
        self.current_task = self.factory.make(mode, self.level, self.lang)
        self.board_widget.set_board(self.current_task.board.copy(stack=False))
        self.prompt.setText(self.current_task.prompt)
        self.answer.clear()
        self.result.setText("")
        self.current_start = time.time()
        if self.timer_mode:
            self.timer.start(1000)
        else:
            self.timer.stop()
            self.timer_label.setText("")

    def new_task(self) -> None:
        mode = self.mode_combo.currentText()
        key = self._mode_key(mode)
        if key not in self.demo_shown_modes:
            self.show_mode_demo_dialog(mode)
            self.pending_mode_after_demo = mode
            self.play_demo_for_current_mode()
            return
        self._start_task(mode)

    def tick(self) -> None:
        elapsed = int(time.time() - self.current_start)
        left = max(0, self.time_limit - elapsed)
        self.timer_label.setText(f"Осталось: {left}с" if self.lang == "ru" else f"Time left: {left}s")
        if left <= 0:
            self.timer.stop()
            self.result.setText("Время вышло." if self.lang == "ru" else "Time is over.")

    def check_answer(self) -> None:
        if not self.current_task:
            return
        self.timer.stop()
        mode = self.current_task.mode
        ok = self._is_correct_answer(mode, self.answer.text(), self.current_task.answer, self.current_task.board)
        elapsed = max(0.1, time.time() - self.current_start)

        self.stats.setdefault("solved", {}).setdefault(mode, 0)
        self.stats.setdefault("correct", {}).setdefault(mode, 0)
        self.stats.setdefault("times", {}).setdefault(mode, [])
        self.stats["solved"][mode] += 1
        self.stats["times"][mode].append(elapsed)
        if ok:
            self.stats["correct"][mode] += 1
            self.result.setText(
                f"✅ Верно. Ответ: {self.current_task.answer}"
                if self.lang == "ru"
                else f"✅ Correct. Answer: {self.current_task.answer}"
            )
        else:
            self.result.setText(
                f"❌ Неверно. Верный ответ: {self.current_task.answer}"
                if self.lang == "ru"
                else f"❌ Incorrect. Correct answer: {self.current_task.answer}"
            )
            self.stats.setdefault("errors", []).append(
                (
                    f"{mode}: ответ '{self.answer.text().strip()}' вместо '{self.current_task.answer}'"
                    if self.lang == "ru"
                    else f"{mode}: answer '{self.answer.text().strip()}' instead of '{self.current_task.answer}'"
                )
            )

        s = self.stats["solved"][mode]
        c = self.stats["correct"][mode]
        acc = (c / s) * 100
        if acc > 85 and self.level < 4:
            self.level += 1
        elif acc < 60 and self.level > 1:
            self.level -= 1

        if self.timer_mode:
            if ok:
                self.time_limit = max(10, self.time_limit - 1)
            else:
                self.time_limit = min(60, self.time_limit + 2)

        self.update_ui_meta()
        self.refresh_stats()
        self.save_stats()

        QtCore.QTimer.singleShot(900, self.new_task)

    def closeEvent(self, event: QtGui.QCloseEvent) -> None:
        self.save_stats()
        super().closeEvent(event)


def show_startup_error(message: str) -> None:
    app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
    msg = QtWidgets.QMessageBox()
    msg.setIcon(QtWidgets.QMessageBox.Icon.Critical)
    msg.setWindowTitle("Chess Lesson 2 Trainer — ошибка запуска")
    msg.setText("Приложение завершилось с ошибкой.")
    msg.setInformativeText(
        f"Подробности сохранены в:\n{CRASH_LOG_PATH}\n\n"
        "Откройте этот файл и отправьте его разработчику."
    )
    msg.setDetailedText(message)
    msg.exec()


def main() -> None:
    app = QtWidgets.QApplication(sys.argv)
    font = app.font()
    font.setPointSize(max(12, font.pointSize() + 1))
    app.setFont(font)
    w = ChessTrainerWindow()
    w.show()
    sys.exit(app.exec())


def run_with_error_dialog() -> None:
    try:
        validate_python_chess_module()
        main()
    except Exception:
        details = traceback.format_exc()
        CRASH_LOG_PATH.write_text(details, encoding="utf-8")
        show_startup_error(details)
        sys.exit(1)


if __name__ == "__main__":
    run_with_error_dialog()
🚫 Common Problems & Fixes
Problem Cause Fix
ModuleNotFoundError: No module named 'chess' python-chess not installed pip install python-chess
AttributeError: module 'chess' has no attribute 'Board' Wrong chess package installed pip uninstall -y chess && pip install python-chess
ModuleNotFoundError: No module named 'PySide6' PySide6 not installed pip install PySide6
Window opens but board is blank Window too small / display scaling Resize the window — the board auto-scales
Stats not saving between sessions Permission issue on home directory Check write access to ~/.lesson2_chess_stats.json
App crashes on launch Check ~/.lesson2_chess_crash.log Send the log file for debugging
🆚 How This Compares to Other Free Trainers
Feature This App Lichess Puzzles ChessTempo
Offline Yes — fully local No No
Adaptive difficulty Auto-adjusts per mode Rating-based matching Rating-based
Position editor Built-in, drag-and-drop No No
Material counting drills 4 dedicated modes Not available Not available
Hanging piece detection Dedicated mode Incidental in puzzles Incidental
Bilingual RU/EN Full interface toggle Community translations English only
Timer mode Adaptive timer built-in Puzzle Rush (premium) Blitz mode
Progress tracking Local JSON, persistent Account-based Account-based
Price Free, open source Free Free + Premium

The key difference: Lichess and ChessTempo train pattern recognition through curated puzzles. This app trains the fundamental evaluation skills that make pattern recognition useful — counting material, spotting hanging pieces, evaluating trades. Different layer of chess thinking.


:high_voltage: Quick Hits

Want Do
:person_running: Fastest start pip install python-chess PySide6 && python chess_lesson2_app.py
:brain: Build board vision Start with Mode 4 (hanging pieces) — it rewires how you scan positions
:stopwatch: Speed training Enable timer mode, survive the shrinking clock
:wrench: Custom positions Toggle position editor, build any scenario, test yourself
:bar_chart: Track improvement Stats auto-save — check accuracy trends per mode over time

If you have any comments, bugs, or suggestions for improvement, please email us. All of them will be sent immediately.


One file. Nine modes. Infinite positions. The chess muscle nobody else is training.

2 Likes