llmchess 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
llmchess-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 oemoem12
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: llmchess
3
+ Version: 1.0.0
4
+ Summary: Play international chess against AI opponents powered by local LLMs
5
+ Author: oemoem12
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/oemoem12/LLMChess
8
+ Project-URL: Repository, https://github.com/oemoem12/LLMChess
9
+ Project-URL: Issues, https://github.com/oemoem12/LLMChess/issues
10
+ Keywords: chess,ai,llm,ollama,llama.cpp,lmstudio,pyqt6,board-game
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: X11 Applications :: Qt
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Games/Entertainment :: Board Games
22
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: PyQt6>=6.5.0
27
+ Requires-Dist: python-chess>=1.10.0
28
+ Requires-Dist: httpx>=0.25.0
29
+ Dynamic: license-file
30
+
31
+ # LLM Chess
32
+
33
+ Play international chess against AI opponents powered by local large language models (LLMs).
34
+
35
+ ## Features
36
+
37
+ - **Interactive chess board** with move highlighting and last-move indicators
38
+ - **Pawn promotion** dialog
39
+ - **Undo moves** support
40
+ - **FEN display** and SAN move history
41
+ - **Dark theme** Catppuccin-style UI
42
+ - **Game status** detection (checkmate, stalemate, draw, etc.)
43
+
44
+ ## Supported LLM Backends
45
+
46
+ Connects to any OpenAI-compatible API endpoint:
47
+
48
+ | Backend | Default URL | Notes |
49
+ |---------|-------------|-------|
50
+ | **Ollama** | `http://localhost:11434` | `ollama serve` |
51
+ | **llama.cpp Server** | `http://localhost:8080` | `./llama-server -m model.gguf --port 8080` |
52
+ | **LM Studio** | `http://localhost:1234` | Enable "Local Server" in Developer tab |
53
+ | **Custom** | Any URL | Configure in Settings |
54
+
55
+ ## Installation
56
+
57
+ ### From DEB package (Ubuntu/Debian)
58
+
59
+ ```bash
60
+ sudo dpkg -i llmchess_1.0.0_all.deb
61
+ llmchess
62
+ ```
63
+
64
+ ### From source
65
+
66
+ ```bash
67
+ # Install dependencies
68
+ pip install PyQt6 python-chess httpx
69
+
70
+ # Run
71
+ python3 main.py
72
+ ```
73
+
74
+ ### Build DEB package
75
+
76
+ ```bash
77
+ bash build_deb.sh
78
+ sudo dpkg -i llmchess_1.0.0_all.deb
79
+ ```
80
+
81
+ ## Usage
82
+
83
+ 1. **Start your LLM server** (Ollama, llama.cpp, or LM Studio)
84
+ 2. **Launch LLM Chess** (`llmchess` or from application menu)
85
+ 3. Click **Settings** to configure your LLM connection
86
+ 4. Select **White** (you move first) or **Black** (AI moves first)
87
+ 5. Click a piece, then click the destination square to move
88
+
89
+ ### Settings
90
+
91
+ - **Provider**: Select your backend (Ollama, llama.cpp, LM Studio, or Custom)
92
+ - **Base URL**: API endpoint (auto-filled for presets)
93
+ - **Model**: Model name to use
94
+ - **Temperature**: Lower = more deterministic moves (default 0.3)
95
+ - **Max Tokens**: Maximum response length (default 512)
96
+ - **Test Connection**: Verify connectivity before playing
97
+
98
+ ## Project Structure
99
+
100
+ ```
101
+ LLMChess/
102
+ ├── main.py # Application entry point
103
+ ├── requirements.txt # Python dependencies
104
+ ├── build_deb.sh # DEB package build script
105
+ ├── chess_app/
106
+ │ ├── board_widget.py # Chess board UI (PyQt6)
107
+ │ ├── llm_connector.py # LLM API connector
108
+ │ ├── game_controller.py # Main window & game controller
109
+ │ └── settings_dialog.py # LLM connection settings
110
+ └── deb_pkg/ # DEB packaging files
111
+ ```
112
+
113
+ ## How It Works
114
+
115
+ 1. Each turn, the current board state is sent to the LLM as a **FEN string** plus a list of legal moves in **UCI format**
116
+ 2. The LLM is prompted to respond with **only one UCI move** (e.g., `e2e4`, `g1f3`)
117
+ 3. The response is parsed to extract the UCI move and validated against legal moves
118
+ 4. If the LLM returns an invalid or empty response, the first legal move is used as fallback
119
+
120
+ ## Requirements
121
+
122
+ - Python 3.9+
123
+ - PyQt6
124
+ - python-chess
125
+ - httpx
126
+ - A running LLM server (Ollama, llama.cpp, or LM Studio)
127
+
128
+ ## License
129
+
130
+ MIT License
@@ -0,0 +1,100 @@
1
+ # LLM Chess
2
+
3
+ Play international chess against AI opponents powered by local large language models (LLMs).
4
+
5
+ ## Features
6
+
7
+ - **Interactive chess board** with move highlighting and last-move indicators
8
+ - **Pawn promotion** dialog
9
+ - **Undo moves** support
10
+ - **FEN display** and SAN move history
11
+ - **Dark theme** Catppuccin-style UI
12
+ - **Game status** detection (checkmate, stalemate, draw, etc.)
13
+
14
+ ## Supported LLM Backends
15
+
16
+ Connects to any OpenAI-compatible API endpoint:
17
+
18
+ | Backend | Default URL | Notes |
19
+ |---------|-------------|-------|
20
+ | **Ollama** | `http://localhost:11434` | `ollama serve` |
21
+ | **llama.cpp Server** | `http://localhost:8080` | `./llama-server -m model.gguf --port 8080` |
22
+ | **LM Studio** | `http://localhost:1234` | Enable "Local Server" in Developer tab |
23
+ | **Custom** | Any URL | Configure in Settings |
24
+
25
+ ## Installation
26
+
27
+ ### From DEB package (Ubuntu/Debian)
28
+
29
+ ```bash
30
+ sudo dpkg -i llmchess_1.0.0_all.deb
31
+ llmchess
32
+ ```
33
+
34
+ ### From source
35
+
36
+ ```bash
37
+ # Install dependencies
38
+ pip install PyQt6 python-chess httpx
39
+
40
+ # Run
41
+ python3 main.py
42
+ ```
43
+
44
+ ### Build DEB package
45
+
46
+ ```bash
47
+ bash build_deb.sh
48
+ sudo dpkg -i llmchess_1.0.0_all.deb
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ 1. **Start your LLM server** (Ollama, llama.cpp, or LM Studio)
54
+ 2. **Launch LLM Chess** (`llmchess` or from application menu)
55
+ 3. Click **Settings** to configure your LLM connection
56
+ 4. Select **White** (you move first) or **Black** (AI moves first)
57
+ 5. Click a piece, then click the destination square to move
58
+
59
+ ### Settings
60
+
61
+ - **Provider**: Select your backend (Ollama, llama.cpp, LM Studio, or Custom)
62
+ - **Base URL**: API endpoint (auto-filled for presets)
63
+ - **Model**: Model name to use
64
+ - **Temperature**: Lower = more deterministic moves (default 0.3)
65
+ - **Max Tokens**: Maximum response length (default 512)
66
+ - **Test Connection**: Verify connectivity before playing
67
+
68
+ ## Project Structure
69
+
70
+ ```
71
+ LLMChess/
72
+ ├── main.py # Application entry point
73
+ ├── requirements.txt # Python dependencies
74
+ ├── build_deb.sh # DEB package build script
75
+ ├── chess_app/
76
+ │ ├── board_widget.py # Chess board UI (PyQt6)
77
+ │ ├── llm_connector.py # LLM API connector
78
+ │ ├── game_controller.py # Main window & game controller
79
+ │ └── settings_dialog.py # LLM connection settings
80
+ └── deb_pkg/ # DEB packaging files
81
+ ```
82
+
83
+ ## How It Works
84
+
85
+ 1. Each turn, the current board state is sent to the LLM as a **FEN string** plus a list of legal moves in **UCI format**
86
+ 2. The LLM is prompted to respond with **only one UCI move** (e.g., `e2e4`, `g1f3`)
87
+ 3. The response is parsed to extract the UCI move and validated against legal moves
88
+ 4. If the LLM returns an invalid or empty response, the first legal move is used as fallback
89
+
90
+ ## Requirements
91
+
92
+ - Python 3.9+
93
+ - PyQt6
94
+ - python-chess
95
+ - httpx
96
+ - A running LLM server (Ollama, llama.cpp, or LM Studio)
97
+
98
+ ## License
99
+
100
+ MIT License
@@ -0,0 +1,58 @@
1
+ import sys
2
+ import os
3
+
4
+ __version__ = "1.0.0"
5
+
6
+
7
+ def main():
8
+ from PyQt6.QtWidgets import QApplication
9
+
10
+ os.environ["QT_QPA_PLATFORM"] = "xcb"
11
+
12
+ from chess_app.game_controller import MainWindow
13
+
14
+ app = QApplication(sys.argv)
15
+ app.setApplicationName("LLM Chess")
16
+ app.setOrganizationName("LLMChess")
17
+ app.setStyle("Fusion")
18
+
19
+ app.setStyleSheet("""
20
+ QMainWindow { background-color: #1E1E2E; }
21
+ QGroupBox {
22
+ color: #CDD6F4; font-weight: bold;
23
+ border: 1px solid #45475A; border-radius: 6px;
24
+ margin-top: 12px; padding-top: 14px;
25
+ }
26
+ QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; }
27
+ QPushButton {
28
+ background-color: #585B70; color: #CDD6F4;
29
+ border: 1px solid #585B70; border-radius: 4px;
30
+ padding: 6px 16px; font-weight: bold;
31
+ }
32
+ QPushButton:hover { background-color: #6C7086; }
33
+ QPushButton:pressed { background-color: #45475A; }
34
+ QPushButton:disabled { background-color: #313244; color: #6C7086; }
35
+ QComboBox {
36
+ background-color: #313244; color: #CDD6F4;
37
+ border: 1px solid #45475A; border-radius: 4px; padding: 4px 8px;
38
+ }
39
+ QComboBox::drop-down { border: none; }
40
+ QComboBox QAbstractItemView {
41
+ background-color: #313244; color: #CDD6F4;
42
+ selection-background-color: #585B70;
43
+ }
44
+ QLabel { color: #CDD6F4; }
45
+ QTextEdit {
46
+ background-color: #313244; color: #CDD6F4;
47
+ border: 1px solid #45475A; border-radius: 4px;
48
+ }
49
+ QLineEdit, QSpinBox, QDoubleSpinBox {
50
+ background-color: #313244; color: #CDD6F4;
51
+ border: 1px solid #45475A; border-radius: 4px; padding: 4px;
52
+ }
53
+ QDialog { background-color: #1E1E2E; }
54
+ """)
55
+
56
+ window = MainWindow()
57
+ window.show()
58
+ sys.exit(app.exec())
@@ -0,0 +1,4 @@
1
+ from chess_app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,385 @@
1
+ import chess
2
+ from PyQt6.QtWidgets import QWidget, QDialog, QVBoxLayout, QHBoxLayout, QPushButton
3
+ from PyQt6.QtCore import Qt, pyqtSignal, QRect, QSize
4
+ from PyQt6.QtGui import QPainter, QColor, QFont, QMouseEvent, QPen, QBrush
5
+
6
+
7
+ PIECE_UNICODE = {
8
+ chess.KING: ("\u2654", "\u265a"),
9
+ chess.QUEEN: ("\u2655", "\u265b"),
10
+ chess.ROOK: ("\u2656", "\u265c"),
11
+ chess.BISHOP: ("\u2657", "\u265d"),
12
+ chess.KNIGHT: ("\u2658", "\u265e"),
13
+ chess.PAWN: ("\u2659", "\u265f"),
14
+ }
15
+
16
+ LIGHT_SQUARE = QColor("#F0D9B5")
17
+ DARK_SQUARE = QColor("#B58863")
18
+ SELECTED_COLOR = QColor("#829769")
19
+ LEGAL_MOVE_COLOR = QColor("#82976955")
20
+ LEGAL_MOVE_DOT_COLOR = QColor("#829769aa")
21
+ LAST_MOVE_COLOR = QColor("#CDD26A")
22
+ CHECK_COLOR = QColor("#FF6B6B")
23
+ BORDER_COLOR = QColor("#4A4A4A")
24
+
25
+
26
+ class PromotionDialog(QDialog):
27
+ def __init__(self, color: chess.Color, parent=None):
28
+ super().__init__(parent)
29
+ self.setWindowTitle("Promote Pawn")
30
+ self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
31
+ self.setModal(True)
32
+ self.piece_type = chess.QUEEN
33
+
34
+ layout = QHBoxLayout(self)
35
+ layout.setSpacing(4)
36
+ layout.setContentsMargins(4, 4, 4, 4)
37
+
38
+ pieces = [
39
+ (chess.QUEEN, "Q"),
40
+ (chess.ROOK, "R"),
41
+ (chess.BISHOP, "B"),
42
+ (chess.KNIGHT, "N"),
43
+ ]
44
+
45
+ for pt, label in pieces:
46
+ piece_char = PIECE_UNICODE[pt][0 if color == chess.WHITE else 1]
47
+ btn = QPushButton(piece_char)
48
+ btn.setFont(QFont("Segoe UI Symbol", 28))
49
+ btn.setFixedSize(52, 52)
50
+ btn.clicked.connect(lambda checked, p=pt: self._select(p))
51
+ layout.addWidget(btn)
52
+
53
+ def _select(self, piece_type: chess.PieceType):
54
+ self.piece_type = piece_type
55
+ self.accept()
56
+
57
+
58
+ class ChessBoardWidget(QWidget):
59
+ move_made = pyqtSignal(str)
60
+
61
+ def __init__(self, parent=None):
62
+ super().__init__(parent)
63
+ self.board = chess.Board()
64
+ self._flipped = False
65
+ self._selected_square: int | None = None
66
+ self._legal_moves: list[chess.Move] = []
67
+ self._last_move: chess.Move | None = None
68
+ self._player_color = chess.WHITE
69
+ self._human_turn = True
70
+
71
+ self.setMinimumSize(480, 480)
72
+ self.setMouseTracking(False)
73
+
74
+ @property
75
+ def square_size(self) -> int:
76
+ return min(self.width(), self.height()) // 8
77
+
78
+ @property
79
+ def board_offset_x(self) -> int:
80
+ return (self.width() - self.square_size * 8) // 2
81
+
82
+ @property
83
+ def board_offset_y(self) -> int:
84
+ return (self.height() - self.square_size * 8) // 2
85
+
86
+ def set_player_color(self, color: chess.Color):
87
+ self._player_color = color
88
+ self._flipped = (color == chess.BLACK)
89
+ self._human_turn = (self.board.turn == self._player_color)
90
+ self.update()
91
+
92
+ def set_position(self, fen: str):
93
+ self.board = chess.Board(fen)
94
+ self._selected_square = None
95
+ self._legal_moves = []
96
+ self._last_move = None
97
+ self._human_turn = (self.board.turn == self._player_color)
98
+ self.update()
99
+
100
+ def apply_move(self, move: chess.Move):
101
+ self._last_move = move
102
+ self.board.push(move)
103
+ self._selected_square = None
104
+ self._legal_moves = []
105
+ self._human_turn = (self.board.turn == self._player_color)
106
+ self.update()
107
+
108
+ def get_legal_moves_uci(self) -> list[str]:
109
+ return [move.uci() for move in self.board.legal_moves]
110
+
111
+ def paintEvent(self, event):
112
+ painter = QPainter(self)
113
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
114
+
115
+ sq_size = self.square_size
116
+ ox = self.board_offset_x
117
+ oy = self.board_offset_y
118
+
119
+ for row in range(8):
120
+ for col in range(8):
121
+ x = ox + col * sq_size
122
+ y = oy + row * sq_size
123
+
124
+ display_row = 7 - row if not self._flipped else row
125
+ display_col = col if not self._flipped else 7 - col
126
+
127
+ is_light = (display_row + display_col) % 2 == 0
128
+ color = LIGHT_SQUARE if is_light else DARK_SQUARE
129
+
130
+ painter.fillRect(QRect(x, y, sq_size, sq_size), color)
131
+
132
+ if self._selected_square is not None:
133
+ sel_col = chess.square_file(self._selected_square)
134
+ sel_row = chess.square_rank(self._selected_square)
135
+
136
+ if self._flipped:
137
+ sel_col = 7 - sel_col
138
+ else:
139
+ sel_row = 7 - sel_row
140
+
141
+ sx = ox + sel_col * sq_size
142
+ sy = oy + sel_row * sq_size
143
+
144
+ painter.fillRect(QRect(sx, sy, sq_size, sq_size), SELECTED_COLOR)
145
+
146
+ for move in self._legal_moves:
147
+ if move.from_square != self._selected_square:
148
+ continue
149
+
150
+ to_sq = move.to_square
151
+ to_col = chess.square_file(to_sq)
152
+ to_row = chess.square_rank(to_sq)
153
+
154
+ if self._flipped:
155
+ to_col = 7 - to_col
156
+ else:
157
+ to_row = 7 - to_row
158
+
159
+ tx = ox + to_col * sq_size
160
+ ty = oy + to_row * sq_size
161
+
162
+ target_piece = self.board.piece_at(to_sq)
163
+ if target_piece is not None:
164
+ painter.setPen(QPen(LEGAL_MOVE_COLOR, 3))
165
+ painter.setBrush(Qt.BrushStyle.NoBrush)
166
+ painter.drawEllipse(QRect(tx + 2, ty + 2, sq_size - 4, sq_size - 4))
167
+ else:
168
+ dot_size = sq_size // 4
169
+ dx = tx + (sq_size - dot_size) // 2
170
+ dy = ty + (sq_size - dot_size) // 2
171
+ painter.setPen(Qt.PenStyle.NoPen)
172
+ painter.setBrush(QBrush(LEGAL_MOVE_DOT_COLOR))
173
+ painter.drawEllipse(QRect(dx, dy, dot_size, dot_size))
174
+
175
+ if self._last_move is not None:
176
+ for sq in (self._last_move.from_square, self._last_move.to_square):
177
+ col = chess.square_file(sq)
178
+ row = chess.square_rank(sq)
179
+
180
+ if self._flipped:
181
+ col = 7 - col
182
+ else:
183
+ row = 7 - row
184
+
185
+ lx = ox + col * sq_size
186
+ ly = oy + row * sq_size
187
+
188
+ painter.fillRect(QRect(lx, ly, sq_size, sq_size), LAST_MOVE_COLOR)
189
+
190
+ king_sq = self.board.king(self.board.turn)
191
+ if king_sq is not None and self.board.is_check():
192
+ col = chess.square_file(king_sq)
193
+ row = chess.square_rank(king_sq)
194
+
195
+ if self._flipped:
196
+ col = 7 - col
197
+ else:
198
+ row = 7 - row
199
+
200
+ kx = ox + col * sq_size
201
+ ky = oy + row * sq_size
202
+
203
+ painter.fillRect(QRect(kx, ky, sq_size, sq_size), CHECK_COLOR)
204
+
205
+ piece_font = QFont("Segoe UI Symbol", int(sq_size * 0.72))
206
+ painter.setFont(piece_font)
207
+ painter.setPen(QColor("#302E2B"))
208
+
209
+ for row in range(8):
210
+ for col in range(8):
211
+ display_row = 7 - row if not self._flipped else row
212
+ display_col = col if not self._flipped else 7 - col
213
+
214
+ square = chess.square(display_col, display_row)
215
+ piece = self.board.piece_at(square)
216
+ if piece is None:
217
+ continue
218
+
219
+ x = ox + col * sq_size
220
+ y = oy + row * sq_size
221
+
222
+ is_white = piece.color == chess.WHITE
223
+ ptype = piece.piece_type
224
+ symbol = PIECE_UNICODE[ptype][0 if is_white else 1]
225
+
226
+ if is_white:
227
+ painter.setPen(QColor("#FFFFFF"))
228
+ shadow_offset = max(1, sq_size // 40)
229
+ painter.drawText(QRect(x + shadow_offset, y + shadow_offset, sq_size, sq_size),
230
+ Qt.AlignmentFlag.AlignCenter, symbol)
231
+ painter.setPen(QColor("#302E2B"))
232
+ else:
233
+ painter.setPen(QColor("#302E2B"))
234
+
235
+ painter.drawText(QRect(x, y, sq_size, sq_size),
236
+ Qt.AlignmentFlag.AlignCenter, symbol)
237
+
238
+ painter.setPen(QPen(BORDER_COLOR, 2))
239
+ painter.setBrush(Qt.BrushStyle.NoBrush)
240
+ painter.drawRect(QRect(ox, oy, sq_size * 8, sq_size * 8))
241
+
242
+ coord_font = QFont("Segoe UI", max(8, sq_size // 6))
243
+ painter.setFont(coord_font)
244
+ painter.setPen(QColor("#4A4A4A"))
245
+
246
+ files = "abcdefgh"
247
+ ranks = "12345678"
248
+
249
+ for i in range(8):
250
+ display_file = files[7 - i if self._flipped else i]
251
+ display_rank = ranks[i if self._flipped else 7 - i]
252
+
253
+ fx = ox + i * sq_size + 3
254
+ fy = oy + 8 * sq_size - 2
255
+ painter.drawText(QRect(fx, fy, sq_size, sq_size // 4),
256
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop,
257
+ display_file)
258
+
259
+ rx = ox - sq_size // 4
260
+ ry = oy + i * sq_size
261
+ painter.drawText(QRect(rx, ry, sq_size // 4, sq_size),
262
+ Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
263
+ display_rank)
264
+
265
+ if self.board.is_game_over():
266
+ result = self.board.result()
267
+ painter.setFont(QFont("Segoe UI", max(16, sq_size // 2)))
268
+ painter.setPen(QPen(QColor("#FFFFFF")))
269
+ painter.fillRect(QRect(ox, oy + sq_size * 3, sq_size * 8, sq_size * 2),
270
+ QColor(0, 0, 0, 180))
271
+
272
+ outcome_text = "Game Over"
273
+ if result == "1-0":
274
+ outcome_text = "White Wins!"
275
+ elif result == "0-1":
276
+ outcome_text = "Black Wins!"
277
+ elif result == "1/2-1/2":
278
+ outcome_text = "Draw!"
279
+
280
+ painter.drawText(QRect(ox, oy + sq_size * 3, sq_size * 8, sq_size * 2),
281
+ Qt.AlignmentFlag.AlignCenter, outcome_text)
282
+
283
+ def mousePressEvent(self, event: QMouseEvent):
284
+ if not self._human_turn or self.board.is_game_over():
285
+ return
286
+
287
+ sq_size = self.square_size
288
+ ox = self.board_offset_x
289
+ oy = self.board_offset_y
290
+
291
+ col = (event.pos().x() - ox) // sq_size
292
+ row = (event.pos().y() - oy) // sq_size
293
+
294
+ if col < 0 or col >= 8 or row < 0 or row >= 8:
295
+ return
296
+
297
+ if self._flipped:
298
+ file_idx = 7 - col
299
+ rank_idx = row
300
+ else:
301
+ file_idx = col
302
+ rank_idx = 7 - row
303
+
304
+ clicked_square = chess.square(file_idx, rank_idx)
305
+
306
+ if self._selected_square is None:
307
+ piece = self.board.piece_at(clicked_square)
308
+ if piece is not None and piece.color == self._player_color:
309
+ self._selected_square = clicked_square
310
+ self._legal_moves = [
311
+ move for move in self.board.legal_moves
312
+ if move.from_square == clicked_square
313
+ ]
314
+ self.update()
315
+ else:
316
+ if clicked_square == self._selected_square:
317
+ self._selected_square = None
318
+ self._legal_moves = []
319
+ self.update()
320
+ return
321
+
322
+ for move in self._legal_moves:
323
+ if move.to_square == clicked_square:
324
+ if move.promotion:
325
+ self._handle_promotion(move, clicked_square)
326
+ else:
327
+ self._execute_move(move)
328
+ return
329
+
330
+ piece = self.board.piece_at(clicked_square)
331
+ if piece is not None and piece.color == self._player_color:
332
+ self._selected_square = clicked_square
333
+ self._legal_moves = [
334
+ move for move in self.board.legal_moves
335
+ if move.from_square == clicked_square
336
+ ]
337
+ self.update()
338
+ else:
339
+ self._selected_square = None
340
+ self._legal_moves = []
341
+ self.update()
342
+
343
+ def _handle_promotion(self, move: chess.Move, to_square: chess.Square):
344
+ dlg = PromotionDialog(self._player_color, self)
345
+ sq_size = self.square_size
346
+ ox = self.board_offset_x
347
+ oy = self.board_offset_y
348
+
349
+ if self._flipped:
350
+ col = 7 - chess.square_file(to_square)
351
+ row = chess.square_rank(to_square)
352
+ else:
353
+ col = chess.square_file(to_square)
354
+ row = 7 - chess.square_rank(to_square)
355
+
356
+ dlg_x = self.mapToGlobal(
357
+ self.rect().topLeft()
358
+ ).x() + ox + col * sq_size + sq_size
359
+ dlg_y = self.mapToGlobal(
360
+ self.rect().topLeft()
361
+ ).y() + oy + row * sq_size
362
+ dlg.move(dlg_x + 10, dlg_y)
363
+
364
+ if dlg.exec() == QDialog.DialogCode.Accepted:
365
+ promotion_move = chess.Move(
366
+ move.from_square, move.to_square, promotion=dlg.piece_type
367
+ )
368
+ self._execute_move(promotion_move)
369
+ else:
370
+ self._selected_square = None
371
+ self._legal_moves = []
372
+ self.update()
373
+
374
+ def _execute_move(self, move: chess.Move):
375
+ self.apply_move(move)
376
+ self.move_made.emit(move.uci())
377
+
378
+ def LLM_play_move(self, uci_move: str):
379
+ try:
380
+ move = chess.Move.from_uci(uci_move)
381
+ except ValueError:
382
+ return
383
+
384
+ if move in self.board.legal_moves:
385
+ self.apply_move(move)