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

**URL:** <https://onehack.st/t/one-script-infinite-positions-desktop-chess-trainer-that-gets-harder-as-you-improve/320956>\
**Category:** Tools & Scripts\
**Tags:** learning, freebies, gaming\
**Created:** [April 9, 2026, 7:37pm UTC](https://onehack.st/t/one-script-infinite-positions-desktop-chess-trainer-that-gets-harder-as-you-improve/320956 "2026-04-09T19:37:36Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![Edgar](https://onehack.st/user_avatar/onehack.st/edgar/32/167100_2.png) [@Edgar](https://onehack.st/u/Edgar)\
**Post date:** [April 9, 2026, 7:37pm UTC](https://onehack.st/t/one-script-infinite-positions-desktop-chess-trainer-that-gets-harder-as-you-improve/320956/1 "2026-04-09T19:37:36Z")

</div>

# ♟ 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?” |
> 
> > 💡 **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:
> 
> ```bash
> pip install python-chess PySide6
> python chess_lesson2_app.py
> 
> ```
> 
> > 💡 **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:
> 
> ```bash
> 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](https://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):
> 
> ```bash
> 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**
>
> ![image](https://onehack.st/uploads/default/original/3X/2/3/2305d8aa939bfc288a3e6650b100cbeea2c99f71.png)
> 
> 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.
> 
> ![image](https://onehack.st/uploads/default/original/3X/b/8/b8b7fdf69b6518e75a372a4e7f2e2746f28a9988.png)
> 
> ![image](https://onehack.st/uploads/default/original/3X/4/6/4632f2328cbed7c49a05338ba4f49dd55209a0b6.png)
> 
> 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.
> 
> ![image](https://onehack.st/uploads/default/original/3X/d/e/de84bf1469b9a487d3d48737abb7a2eb42fbf7bb.png)
> 
> ![image](https://onehack.st/uploads/default/original/3X/a/9/a929fb3eac348e14ab44afb9ed31c6b3785b37bd.png)
> 
> ![image](https://onehack.st/uploads/default/original/3X/6/9/692e3441cca4142945713297ca96d65ab9435fd1.png)
> 
> ![image](https://onehack.st/uploads/default/original/3X/0/3/03e9b92c520ebac5389a6d061679981f9dfd3e77.png)
> 
> ![image](https://onehack.st/uploads/default/original/3X/5/a/5af06db834a901959e81017f40e4625183bd7de2.png)
> 
> ![image](https://onehack.st/uploads/default/original/3X/b/e/be310b7d3574a3d0af4eb586da7dbd899d227e51.png)

> **🎯 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.
> 
> ```python
> #!/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**](https://lichess.org/training) | [**ChessTempo**](https://chesstempo.com) |
> | --- | --- | --- | --- |
> | **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.

* * *

**⚡ Quick Hits**

| Want | Do |
| --- | --- |
| 🏃 Fastest start | `pip install python-chess PySide6 && python chess_lesson2_app.py` |
| 🧠 Build board vision | Start with Mode 4 (hanging pieces) — it rewires how you scan positions |
| ⏱ Speed training | Enable timer mode, survive the shrinking clock |
| 🔧 Custom positions | Toggle position editor, build any scenario, test yourself |
| 📊 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._
