FlameF0X commited on
Commit
478efec
Β·
verified Β·
1 Parent(s): 27bd45b

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +572 -0
app.py ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ChessSLM β€” Play against FlameF0X/ChessSLM
3
+ Hugging Face Space | Gradio app
4
+ """
5
+
6
+ import re
7
+ import random
8
+ import time
9
+ import chess
10
+ import chess.svg
11
+ import chess.pgn
12
+ import gradio as gr
13
+ import torch
14
+ from transformers import GPT2LMHeadModel, GPT2Tokenizer
15
+
16
+ # ──────────────────────────────────────────────────────────────────────────────
17
+ # Model loading (once at startup)
18
+ # ──────────────────────────────────────────────────────────────────────────────
19
+
20
+ MODEL_ID = "FlameF0X/ChessSLM"
21
+
22
+ print(f"Loading {MODEL_ID}...")
23
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
24
+ tokenizer = GPT2Tokenizer.from_pretrained(MODEL_ID)
25
+ tokenizer.pad_token = tokenizer.eos_token
26
+ model = GPT2LMHeadModel.from_pretrained(MODEL_ID)
27
+ model.to(device)
28
+ model.eval()
29
+ model.config.use_cache = True
30
+ print(f"βœ“ Model ready on {device}")
31
+
32
+ # ──────────────────────────────────────────────────────────────────────────────
33
+ # Chess / model logic
34
+ # ──────────────────────────────────────────────────────────────────────────────
35
+
36
+ def board_to_prompt(board: chess.Board) -> str:
37
+ game = chess.pgn.Game()
38
+ node = game
39
+ for move in board.move_stack:
40
+ node = node.add_variation(move)
41
+ exporter = chess.pgn.StringExporter(headers=False, variations=False, comments=False)
42
+ pgn = game.accept(exporter).strip()
43
+ pgn = re.sub(r"\s*[\*\d][-\d/]*\s*$", "", pgn).strip()
44
+ full_move = board.fullmove_number
45
+ pgn += f" {full_move}." if board.turn == chess.WHITE else f" {full_move}..."
46
+ return f"<|endoftext|>{pgn}"
47
+
48
+
49
+ def extract_move(text: str, board: chess.Board):
50
+ text = re.sub(r"^\s*\d+\.+\s*", "", text).strip()
51
+ for token in text.split()[:5]:
52
+ clean = re.sub(r"[!?+#,;]+$", "", token)
53
+ try:
54
+ move = board.parse_san(clean)
55
+ if move in board.legal_moves:
56
+ return move
57
+ except Exception:
58
+ pass
59
+ try:
60
+ move = chess.Move.from_uci(clean.lower()[:4])
61
+ if move in board.legal_moves:
62
+ return move
63
+ except Exception:
64
+ pass
65
+ return None
66
+
67
+
68
+ @torch.no_grad()
69
+ def get_model_move(board: chess.Board):
70
+ prompt = board_to_prompt(board)
71
+ inputs = tokenizer(prompt, return_tensors="pt").to(device)
72
+ outputs = model.generate(
73
+ inputs.input_ids,
74
+ max_new_tokens=12,
75
+ do_sample=True,
76
+ temperature=0.3,
77
+ top_k=40,
78
+ top_p=0.9,
79
+ repetition_penalty=1.1,
80
+ pad_token_id=tokenizer.eos_token_id,
81
+ eos_token_id=tokenizer.eos_token_id,
82
+ )
83
+ new_tokens = outputs[0][inputs.input_ids.shape[1]:]
84
+ generated = tokenizer.decode(new_tokens, skip_special_tokens=True)
85
+ move = extract_move(generated, board)
86
+ if move:
87
+ return move, True
88
+ return random.choice(list(board.legal_moves)), False
89
+
90
+ # ──────────────────────────────────────────────────────────────────────────────
91
+ # Board rendering
92
+ # ──────────────────────────────────────────────────────────────────────────────
93
+
94
+ PIECE_COLORS = {
95
+ "square light": "#f0d9b5",
96
+ "square dark": "#b58863",
97
+ "square light lastmove": "#cdd16e",
98
+ "square dark lastmove": "#aaa23a",
99
+ }
100
+
101
+ def render_board_html(board: chess.Board, last_move=None, flipped=False, size=480):
102
+ check_square = None
103
+ if board.is_check():
104
+ check_square = board.king(board.turn)
105
+
106
+ svg = chess.svg.board(
107
+ board,
108
+ lastmove=last_move,
109
+ check=check_square,
110
+ flipped=flipped,
111
+ size=size,
112
+ colors=PIECE_COLORS,
113
+ )
114
+ return f"""
115
+ <div style="
116
+ display:flex; justify-content:center; align-items:center;
117
+ padding: 16px;
118
+ background: radial-gradient(ellipse at center, #1a1208 0%, #0d0d0d 100%);
119
+ border-radius: 12px;
120
+ box-shadow: 0 0 60px rgba(0,0,0,0.8), inset 0 0 30px rgba(0,0,0,0.4);
121
+ ">
122
+ <div style="
123
+ border-radius: 4px;
124
+ overflow: hidden;
125
+ box-shadow: 0 8px 32px rgba(0,0,0,0.6), 0 0 0 3px #3d2b0e, 0 0 0 5px #6b4c1e;
126
+ ">
127
+ {svg}
128
+ </div>
129
+ </div>
130
+ """
131
+
132
+
133
+ def get_legal_moves_san(board: chess.Board):
134
+ moves = []
135
+ for move in board.legal_moves:
136
+ try:
137
+ moves.append(board.san(move))
138
+ except Exception:
139
+ pass
140
+ return sorted(moves)
141
+
142
+
143
+ def format_move_history(board: chess.Board):
144
+ if not board.move_stack:
145
+ return "<em style='color:#666'>No moves yet.</em>"
146
+
147
+ temp = chess.Board()
148
+ lines = []
149
+ moves = list(board.move_stack)
150
+ i = 0
151
+
152
+ while i < len(moves):
153
+ move_num = temp.fullmove_number
154
+ white_san = temp.san(moves[i])
155
+ temp.push(moves[i])
156
+ i += 1
157
+
158
+ if i < len(moves):
159
+ black_san = temp.san(moves[i])
160
+ temp.push(moves[i])
161
+ i += 1
162
+ lines.append(
163
+ f"<span style='color:#8a7a5a;font-size:0.8em'>{move_num}.</span> "
164
+ f"<span style='color:#e8d5a3'>{white_san}</span> "
165
+ f"<span style='color:#c4b48a'>{black_san}</span>"
166
+ )
167
+ else:
168
+ lines.append(
169
+ f"<span style='color:#8a7a5a;font-size:0.8em'>{move_num}.</span> "
170
+ f"<span style='color:#e8d5a3'>{white_san}</span>"
171
+ )
172
+
173
+ # Last 10 pairs
174
+ visible = lines[-10:]
175
+ html = "<div style='font-family:\"Courier New\",monospace; line-height:2; font-size:0.92em;'>"
176
+ html += "<br>".join(visible)
177
+ html += "</div>"
178
+ return html
179
+
180
+
181
+ def game_status(board: chess.Board, player_color: str):
182
+ if board.is_checkmate():
183
+ loser = "White" if board.turn == chess.WHITE else "Black"
184
+ winner = "Black" if board.turn == chess.WHITE else "White"
185
+ if (winner == "White") == (player_color == "white"):
186
+ return "β™Ÿ Checkmate β€” You win! πŸŽ‰", "win"
187
+ else:
188
+ return "β™Ÿ Checkmate β€” ChessSLM wins!", "loss"
189
+ if board.is_stalemate():
190
+ return "Β½ Stalemate β€” Draw", "draw"
191
+ if board.is_insufficient_material():
192
+ return "Β½ Insufficient material β€” Draw", "draw"
193
+ if board.is_seventyfive_moves():
194
+ return "Β½ 75-move rule β€” Draw", "draw"
195
+ if board.is_fivefold_repetition():
196
+ return "Β½ Fivefold repetition β€” Draw", "draw"
197
+ if board.is_check():
198
+ return "⚠ Check!", "check"
199
+ whose = "Your turn" if (board.turn == chess.WHITE) == (player_color == "white") else "ChessSLM is thinking..."
200
+ return whose, "playing"
201
+
202
+ # ──────────────────────────────────────────────────────────────────────────────
203
+ # Gradio callbacks
204
+ # ──────────────────────────────────────────────────────────────────────────────
205
+
206
+ def new_game(player_color_choice: str):
207
+ """Reset the board and, if player chose Black, let the model move first."""
208
+ board = chess.Board()
209
+ player_color = "white" if player_color_choice == "⬜ White (move first)" else "black"
210
+ flipped = (player_color == "black")
211
+ last_move = None
212
+ log_lines = []
213
+
214
+ # If player chose Black, model plays White first
215
+ if player_color == "black":
216
+ move, legal = get_model_move(board)
217
+ san = board.san(move)
218
+ board.push(move)
219
+ last_move = move
220
+ log_lines.append(f"ChessSLM opens with **{san}**")
221
+
222
+ legal_moves = get_legal_moves_san(board)
223
+ status_text, _ = game_status(board, player_color)
224
+ board_html = render_board_html(board, last_move=last_move, flipped=flipped)
225
+ history_html = format_move_history(board)
226
+ log_html = "<br>".join(log_lines) if log_lines else "<em style='color:#666'>Game started.</em>"
227
+
228
+ # Serialise state
229
+ state = {
230
+ "fen": board.fen(),
231
+ "move_stack": [m.uci() for m in board.move_stack],
232
+ "player_color": player_color,
233
+ "last_move_uci": last_move.uci() if last_move else None,
234
+ "game_over": False,
235
+ }
236
+
237
+ return (
238
+ board_html,
239
+ gr.Dropdown(choices=legal_moves, value=None, interactive=True, label="Your move"),
240
+ status_text,
241
+ history_html,
242
+ log_html,
243
+ state,
244
+ )
245
+
246
+
247
+ def make_player_move(move_san: str, state: dict):
248
+ """Apply the player's chosen move, then let the model respond."""
249
+ if not state or state.get("game_over"):
250
+ return (
251
+ gr.update(), gr.update(), "Game is over. Start a new game.", gr.update(), gr.update(), state
252
+ )
253
+ if not move_san:
254
+ return (
255
+ gr.update(), gr.update(), "Please select a move first.", gr.update(), gr.update(), state
256
+ )
257
+
258
+ # Rebuild board from state
259
+ board = chess.Board()
260
+ for uci in state["move_stack"]:
261
+ board.push(chess.Move.from_uci(uci))
262
+
263
+ player_color = state["player_color"]
264
+ flipped = (player_color == "black")
265
+ log_lines = []
266
+
267
+ # Apply player move
268
+ try:
269
+ player_move = board.parse_san(move_san)
270
+ except Exception:
271
+ return (
272
+ gr.update(), gr.update(), f"Invalid move: {move_san}", gr.update(), gr.update(), state
273
+ )
274
+
275
+ board.push(player_move)
276
+ log_lines.append(f"You played **{move_san}**")
277
+ last_move = player_move
278
+
279
+ # Check game over after player move
280
+ status_text, status_key = game_status(board, player_color)
281
+ game_over = status_key in ("win", "loss", "draw")
282
+
283
+ model_san = None
284
+ if not game_over:
285
+ # Model responds
286
+ model_move_obj, legal = get_model_move(board)
287
+ model_san = board.san(model_move_obj)
288
+ board.push(model_move_obj)
289
+ last_move = model_move_obj
290
+ flag = "" if legal else " *(random fallback)*"
291
+ log_lines.append(f"ChessSLM plays **{model_san}**{flag}")
292
+
293
+ status_text, status_key = game_status(board, player_color)
294
+ game_over = status_key in ("win", "loss", "draw")
295
+
296
+ # Update state
297
+ state = {
298
+ "fen": board.fen(),
299
+ "move_stack": [m.uci() for m in board.move_stack],
300
+ "player_color": player_color,
301
+ "last_move_uci": last_move.uci() if last_move else None,
302
+ "game_over": game_over,
303
+ }
304
+
305
+ legal_moves = [] if game_over else get_legal_moves_san(board)
306
+ board_html = render_board_html(board, last_move=last_move, flipped=flipped)
307
+ history_html = format_move_history(board)
308
+ log_html = "<br>".join(
309
+ [f"<span style='color:#c8a96e'>{l}</span>" for l in log_lines]
310
+ )
311
+
312
+ return (
313
+ board_html,
314
+ gr.Dropdown(choices=legal_moves, value=None, interactive=not game_over, label="Your move"),
315
+ status_text,
316
+ history_html,
317
+ log_html,
318
+ state,
319
+ )
320
+
321
+ # ──────────────────────────────────────────────────────────────────────────────
322
+ # CSS
323
+ # ──────────────────────────────────────────────────────────────────────────────
324
+
325
+ CSS = """
326
+ @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
327
+
328
+ body, .gradio-container {
329
+ background: #0d0d0d !important;
330
+ color: #e8d5a3 !important;
331
+ }
332
+
333
+ .gradio-container {
334
+ max-width: 1100px !important;
335
+ margin: 0 auto !important;
336
+ font-family: 'Crimson Text', Georgia, serif !important;
337
+ }
338
+
339
+ h1, h2, h3 {
340
+ font-family: 'Cinzel', serif !important;
341
+ letter-spacing: 0.08em;
342
+ }
343
+
344
+ /* Title */
345
+ #title-block {
346
+ text-align: center;
347
+ padding: 2rem 0 1rem;
348
+ border-bottom: 1px solid #3d2b0e;
349
+ margin-bottom: 1.5rem;
350
+ }
351
+
352
+ /* Panels */
353
+ .panel {
354
+ background: #141008 !important;
355
+ border: 1px solid #3d2b0e !important;
356
+ border-radius: 8px !important;
357
+ padding: 1rem !important;
358
+ }
359
+
360
+ /* Status bar */
361
+ #status-bar {
362
+ text-align: center;
363
+ font-family: 'Cinzel', serif;
364
+ font-size: 1.1em;
365
+ letter-spacing: 0.05em;
366
+ padding: 0.6rem 1rem;
367
+ border-radius: 6px;
368
+ background: #1a1208;
369
+ border: 1px solid #4a3520;
370
+ color: #f0c060;
371
+ }
372
+
373
+ /* Buttons */
374
+ button.primary {
375
+ background: linear-gradient(135deg, #8b6914 0%, #c4922a 50%, #8b6914 100%) !important;
376
+ border: 1px solid #d4a843 !important;
377
+ color: #fff8e8 !important;
378
+ font-family: 'Cinzel', serif !important;
379
+ letter-spacing: 0.06em !important;
380
+ font-size: 0.9em !important;
381
+ border-radius: 4px !important;
382
+ transition: all 0.2s ease !important;
383
+ }
384
+ button.primary:hover {
385
+ background: linear-gradient(135deg, #a07820 0%, #d4a843 50%, #a07820 100%) !important;
386
+ box-shadow: 0 0 16px rgba(212,168,67,0.4) !important;
387
+ }
388
+
389
+ button.secondary {
390
+ background: #1e1810 !important;
391
+ border: 1px solid #5a4020 !important;
392
+ color: #c8a96e !important;
393
+ font-family: 'Cinzel', serif !important;
394
+ letter-spacing: 0.04em !important;
395
+ border-radius: 4px !important;
396
+ }
397
+
398
+ /* Dropdown */
399
+ select, .gr-dropdown select {
400
+ background: #1a1208 !important;
401
+ border: 1px solid #5a4020 !important;
402
+ color: #e8d5a3 !important;
403
+ font-family: 'Crimson Text', serif !important;
404
+ font-size: 1em !important;
405
+ }
406
+
407
+ /* Move log */
408
+ #move-log {
409
+ background: #0f0c06 !important;
410
+ border: 1px solid #3d2b0e !important;
411
+ border-radius: 6px;
412
+ padding: 0.8rem 1rem;
413
+ font-family: 'Crimson Text', serif;
414
+ font-size: 0.95em;
415
+ line-height: 1.8;
416
+ min-height: 80px;
417
+ color: #c8a96e;
418
+ }
419
+
420
+ /* History panel */
421
+ #history-panel {
422
+ background: #0f0c06 !important;
423
+ border: 1px solid #3d2b0e !important;
424
+ border-radius: 6px;
425
+ padding: 0.8rem 1rem;
426
+ min-height: 200px;
427
+ max-height: 320px;
428
+ overflow-y: auto;
429
+ }
430
+
431
+ /* Radio buttons */
432
+ .gr-radio label {
433
+ color: #e8d5a3 !important;
434
+ font-family: 'Crimson Text', serif !important;
435
+ }
436
+
437
+ /* Labels */
438
+ label span {
439
+ color: #a08050 !important;
440
+ font-family: 'Cinzel', serif !important;
441
+ font-size: 0.8em !important;
442
+ letter-spacing: 0.06em !important;
443
+ text-transform: uppercase !important;
444
+ }
445
+
446
+ /* Scrollbar */
447
+ ::-webkit-scrollbar { width: 6px; }
448
+ ::-webkit-scrollbar-track { background: #0d0d0d; }
449
+ ::-webkit-scrollbar-thumb { background: #5a4020; border-radius: 3px; }
450
+ """
451
+
452
+ # ──────────────────────────────────────────────────────────────────────────────
453
+ # Layout
454
+ # ──────────────────────────────────────────────────────────────────────────────
455
+
456
+ with gr.Blocks(css=CSS, title="ChessSLM β€” Play vs AI") as demo:
457
+
458
+ state = gr.State({})
459
+
460
+ # ── Header ────────────────────────────────────────────────────────────────
461
+ gr.HTML("""
462
+ <div id="title-block">
463
+ <h1 style="
464
+ font-family:'Cinzel',serif;
465
+ font-size:2.4em;
466
+ font-weight:700;
467
+ color:#e8c96e;
468
+ text-shadow: 0 0 30px rgba(232,180,80,0.4);
469
+ margin:0 0 0.3rem;
470
+ letter-spacing:0.12em;
471
+ ">β™› ChessSLM</h1>
472
+ <p style="
473
+ font-family:'Crimson Text',serif;
474
+ color:#8a7a5a;
475
+ font-size:1.1em;
476
+ font-style:italic;
477
+ margin:0;
478
+ ">Play against a GPT-2 model trained on 100,000 chess games</p>
479
+ </div>
480
+ """)
481
+
482
+ # ── Main layout ───────────────────────────────────────────────────────────
483
+ with gr.Row():
484
+
485
+ # Left: board
486
+ with gr.Column(scale=3):
487
+ board_display = gr.HTML(
488
+ value=render_board_html(chess.Board()),
489
+ label="Board"
490
+ )
491
+ status_display = gr.HTML(
492
+ value="<div id='status-bar'>Choose your colour and press New Game</div>"
493
+ )
494
+
495
+ # Right: controls + history
496
+ with gr.Column(scale=2):
497
+
498
+ gr.HTML("<h3 style='font-family:Cinzel,serif;color:#c8a96e;font-size:1em;letter-spacing:0.1em;margin:0 0 0.5rem;'>NEW GAME</h3>")
499
+
500
+ color_choice = gr.Radio(
501
+ choices=["⬜ White (move first)", "⬛ Black (move second)"],
502
+ value="⬜ White (move first)",
503
+ label="Play as",
504
+ )
505
+
506
+ new_game_btn = gr.Button("β™Ÿ New Game", variant="primary", size="lg")
507
+
508
+ gr.HTML("<div style='height:1px;background:#3d2b0e;margin:1rem 0;'></div>")
509
+
510
+ gr.HTML("<h3 style='font-family:Cinzel,serif;color:#c8a96e;font-size:1em;letter-spacing:0.1em;margin:0 0 0.5rem;'>YOUR MOVE</h3>")
511
+
512
+ move_dropdown = gr.Dropdown(
513
+ choices=[],
514
+ value=None,
515
+ label="Select move (SAN notation)",
516
+ interactive=False,
517
+ )
518
+
519
+ move_btn = gr.Button("β–Ά Make Move", variant="secondary")
520
+
521
+ gr.HTML("<div style='height:1px;background:#3d2b0e;margin:1rem 0;'></div>")
522
+
523
+ gr.HTML("<h3 style='font-family:Cinzel,serif;color:#c8a96e;font-size:1em;letter-spacing:0.1em;margin:0 0 0.5rem;'>MOVE LOG</h3>")
524
+
525
+ log_display = gr.HTML(
526
+ value="<div id='move-log'><em style='color:#555'>Start a new game to begin.</em></div>",
527
+ )
528
+
529
+ gr.HTML("<div style='height:1px;background:#3d2b0e;margin:1rem 0;'></div>")
530
+
531
+ gr.HTML("<h3 style='font-family:Cinzel,serif;color:#c8a96e;font-size:1em;letter-spacing:0.1em;margin:0 0 0.5rem;'>GAME HISTORY</h3>")
532
+
533
+ history_display = gr.HTML(
534
+ value="<div id='history-panel'><em style='color:#555'>No moves yet.</em></div>",
535
+ )
536
+
537
+ # ── Footer ────────────────────────────────────────────────────────────────
538
+ gr.HTML("""
539
+ <div style="
540
+ text-align:center; margin-top:2rem; padding-top:1rem;
541
+ border-top:1px solid #2a1e0a;
542
+ font-family:'Crimson Text',serif; font-size:0.85em; color:#5a4a30;
543
+ ">
544
+ Model: <a href="https://huggingface.co/FlameF0X/ChessSLM" target="_blank"
545
+ style="color:#8a6a30; text-decoration:none;">FlameF0X/ChessSLM</a>
546
+ &nbsp;Β·&nbsp; GPT-2 pre-trained on 100K PGN games
547
+ &nbsp;Β·&nbsp; Move selection uses top-k sampling (temp=0.3)
548
+ </div>
549
+ """)
550
+
551
+ # ── Wiring ────────────────────────────────────────────────────────────────
552
+ new_game_btn.click(
553
+ fn=new_game,
554
+ inputs=[color_choice],
555
+ outputs=[board_display, move_dropdown, status_display, history_display, log_display, state],
556
+ )
557
+
558
+ move_btn.click(
559
+ fn=make_player_move,
560
+ inputs=[move_dropdown, state],
561
+ outputs=[board_display, move_dropdown, status_display, history_display, log_display, state],
562
+ )
563
+
564
+ move_dropdown.select(
565
+ fn=make_player_move,
566
+ inputs=[move_dropdown, state],
567
+ outputs=[board_display, move_dropdown, status_display, history_display, log_display, state],
568
+ )
569
+
570
+
571
+ if __name__ == "__main__":
572
+ demo.launch()