Amogh1221 commited on
Commit
ebcce7d
·
verified ·
1 Parent(s): aa4e8b5

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +278 -254
main.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from pydantic import BaseModel
@@ -9,73 +10,175 @@ import chess.engine
9
  import asyncio
10
  import json
11
 
12
- app = FastAPI(title="Deepcastle Engine API")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- # ─── Multiplaying / Challenge Manager ──────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  class ConnectionManager:
16
  def __init__(self):
17
- # match_id -> list of websockets
18
  self.active_connections: Dict[str, List[WebSocket]] = {}
19
 
20
  async def connect(self, websocket: WebSocket, match_id: str):
21
  await websocket.accept()
22
- if match_id not in self.active_connections:
23
- self.active_connections[match_id] = []
24
- self.active_connections[match_id].append(websocket)
25
 
26
  def disconnect(self, websocket: WebSocket, match_id: str):
27
  if match_id in self.active_connections:
28
- if websocket in self.active_connections[match_id]:
29
- self.active_connections[match_id].remove(websocket)
 
30
  if not self.active_connections[match_id]:
31
  del self.active_connections[match_id]
32
 
 
 
 
 
 
 
33
  async def broadcast(self, message: str, match_id: str, exclude: WebSocket = None):
34
- if match_id in self.active_connections:
35
- for connection in self.active_connections[match_id]:
36
- if connection != exclude:
37
- try:
38
- await connection.send_text(message)
39
- except Exception:
40
- pass
41
 
42
  manager = ConnectionManager()
43
 
44
  @app.websocket("/ws/{match_id}")
45
  async def websocket_endpoint(websocket: WebSocket, match_id: str):
46
  await manager.connect(websocket, match_id)
47
- room = manager.active_connections.get(match_id, [])
48
- # Notify others that someone joined
49
  await manager.broadcast(json.dumps({"type": "join"}), match_id, exclude=websocket)
50
  try:
51
  while True:
52
  data = await websocket.receive_text()
53
- # Relay the message (move, etc.) to others in the same room
54
  await manager.broadcast(data, match_id, exclude=websocket)
55
  except WebSocketDisconnect:
56
  manager.disconnect(websocket, match_id)
57
- # Notify remaining players that opponent disconnected → they win
58
  await manager.broadcast(json.dumps({"type": "opponent_disconnected"}), match_id)
59
  except Exception:
60
  manager.disconnect(websocket, match_id)
61
  await manager.broadcast(json.dumps({"type": "opponent_disconnected"}), match_id)
62
 
63
 
64
- # Allow ALL for easy testing (we can restrict this later if needed)
65
- app.add_middleware(
66
- CORSMiddleware,
67
- allow_origins=["*"],
68
- allow_methods=["*"],
69
- allow_headers=["*"],
70
- )
71
-
72
- # Paths relative to the Docker container
73
- ENGINE_PATH = os.environ.get("ENGINE_PATH", "/app/engine/deepcastle")
74
- NNUE_PATH = os.environ.get("NNUE_PATH", "/app/engine/output.nnue")
75
-
76
  class MoveRequest(BaseModel):
77
  fen: str
78
- time: float = 1.0 # seconds
79
  depth: Optional[int] = None
80
 
81
  class MoveResponse(BaseModel):
@@ -110,30 +213,9 @@ class AnalyzeResponse(BaseModel):
110
  moves: List[MoveAnalysis]
111
  counts: Dict[str, int]
112
 
113
- @app.get("/")
114
- def home():
115
- return {"status": "online", "engine": "Deepcastle Hybrid Neural", "platform": "Hugging Face Spaces"}
116
-
117
- @app.get("/health")
118
- def health():
119
- if not os.path.exists(ENGINE_PATH):
120
- return {"status": "error", "message": "Engine binary not found"}
121
- return {"status": "ok", "engine": "Deepcastle"}
122
-
123
- async def get_engine():
124
- if not os.path.exists(ENGINE_PATH):
125
- raise HTTPException(status_code=500, detail="Engine binary not found")
126
- transport, engine = await chess.engine.popen_uci(ENGINE_PATH)
127
- if os.path.exists(NNUE_PATH):
128
- try:
129
- await engine.configure({"EvalFile": NNUE_PATH})
130
- await engine.configure({"Hash": 512, "Threads": 2})
131
- except Exception:
132
- pass
133
- return engine
134
 
 
135
  def get_normalized_score(info) -> tuple[float, Optional[int]]:
136
- """Returns the score from White's perspective in centipawns."""
137
  if "score" not in info:
138
  return 0.0, None
139
  raw = info["score"].white()
@@ -142,80 +224,6 @@ def get_normalized_score(info) -> tuple[float, Optional[int]]:
142
  return (10000.0 if m > 0 else -10000.0), m
143
  return raw.score() or 0.0, None
144
 
145
- # ─── Engine Inference Route ────────────────────��───────────────────────────────
146
- @app.post("/move", response_model=MoveResponse)
147
- async def get_move(request: MoveRequest):
148
- engine = None
149
- try:
150
- engine = await get_engine()
151
- board = chess.Board(request.fen)
152
- limit = chess.engine.Limit(time=request.time, depth=request.depth)
153
-
154
- result = await engine.play(board, limit)
155
- info = await engine.analyse(board, limit)
156
-
157
- # From White's perspective in CP -> converted to Pawns for UI
158
- score_cp, mate_in = get_normalized_score(info)
159
-
160
- depth = info.get("depth", 0)
161
- nodes = info.get("nodes", 0)
162
- nps = info.get("nps", 0)
163
-
164
- pv_board = board.copy()
165
- pv_parts = []
166
- for m in info.get("pv", [])[:5]:
167
- if m in pv_board.legal_moves:
168
- try:
169
- pv_parts.append(pv_board.san(m))
170
- pv_board.push(m)
171
- except Exception:
172
- break
173
- else:
174
- break
175
- pv = " ".join(pv_parts)
176
-
177
- # Map mate score to pawns representation to not break old UI
178
- score_pawns = score_cp / 100.0 if abs(score_cp) < 9900 else (100.0 if score_cp > 0 else -100.0)
179
-
180
- # Check for opening name
181
- board_fen_only = board.fen().split(" ")[0]
182
- opening_name = openings_db.get(board_fen_only)
183
-
184
- return MoveResponse(
185
- bestmove=result.move.uci(),
186
- score=score_pawns,
187
- depth=depth,
188
- nodes=nodes,
189
- nps=nps,
190
- pv=pv,
191
- mate_in=mate_in,
192
- opening=opening_name
193
- )
194
- except Exception as e:
195
- print(f"Error: {e}")
196
- raise HTTPException(status_code=500, detail=str(e))
197
- finally:
198
- if engine:
199
- try:
200
- await engine.quit()
201
- except Exception:
202
- pass
203
-
204
-
205
- import math
206
- import json
207
- import os
208
- from typing import Optional, List, Tuple
209
-
210
- openings_db = {}
211
- openings_path = os.path.join(os.path.dirname(__file__), "openings.json")
212
- if os.path.exists(openings_path):
213
- try:
214
- with open(openings_path, "r", encoding="utf-8") as f:
215
- openings_db = json.load(f)
216
- except Exception as e:
217
- pass
218
-
219
  def get_win_percentage_from_cp(cp: int) -> float:
220
  cp_ceiled = max(-1000, min(1000, cp))
221
  MULTIPLIER = -0.00368208
@@ -228,18 +236,20 @@ def get_win_percentage(info: dict) -> float:
228
  return 50.0
229
  white_score = score.white()
230
  if white_score.is_mate():
231
- mate_val = white_score.mate()
232
- return 100.0 if mate_val > 0 else 0.0
233
  return get_win_percentage_from_cp(white_score.score())
234
 
235
  def is_losing_or_alt_winning(pos_win_pct: float, alt_win_pct: float, is_white_move: bool) -> bool:
236
- is_losing = pos_win_pct < 50.0 if is_white_move else pos_win_pct > 50.0
237
  is_alt_winning = alt_win_pct > 97.0 if is_white_move else alt_win_pct < 3.0
238
  return is_losing or is_alt_winning
239
 
240
  def get_has_changed_outcome(last_win_pct: float, pos_win_pct: float, is_white_move: bool) -> bool:
241
  diff = (pos_win_pct - last_win_pct) * (1 if is_white_move else -1)
242
- return diff > 10.0 and ((last_win_pct < 50.0 and pos_win_pct > 50.0) or (last_win_pct > 50.0 and pos_win_pct < 50.0))
 
 
 
243
 
244
  def get_is_only_good_move(pos_win_pct: float, alt_win_pct: float, is_white_move: bool) -> bool:
245
  diff = (pos_win_pct - alt_win_pct) * (1 if is_white_move else -1)
@@ -252,7 +262,8 @@ def is_simple_recapture(fen_two_moves_ago: str, previous_move: chess.Move, playe
252
  return b.piece_at(previous_move.to_square) is not None
253
 
254
  def get_material_difference(board: chess.Board) -> int:
255
- values = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3, chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 0}
 
256
  w = sum(values.get(p.piece_type, 0) for p in board.piece_map().values() if p.color == chess.WHITE)
257
  b = sum(values.get(p.piece_type, 0) for p in board.piece_map().values() if p.color == chess.BLACK)
258
  return w - b
@@ -260,29 +271,20 @@ def get_material_difference(board: chess.Board) -> int:
260
  def get_is_piece_sacrifice(board: chess.Board, played_move: chess.Move, best_pv: list) -> bool:
261
  if not best_pv:
262
  return False
263
- start_diff = get_material_difference(board)
264
  white_to_play = board.turn == chess.WHITE
265
-
266
  sim_board = board.copy()
267
  moves = [played_move] + best_pv
268
  if len(moves) % 2 == 1:
269
  moves = moves[:-1]
270
-
271
- captured_w = []
272
- captured_b = []
273
  non_capturing = 1
274
-
275
  for m in moves:
276
  if m in sim_board.legal_moves:
277
  captured_piece = sim_board.piece_at(m.to_square)
278
  if sim_board.is_en_passant(m):
279
  captured_piece = chess.Piece(chess.PAWN, not sim_board.turn)
280
-
281
  if captured_piece:
282
- if sim_board.turn == chess.WHITE:
283
- captured_b.append(captured_piece.piece_type)
284
- else:
285
- captured_w.append(captured_piece.piece_type)
286
  non_capturing = 1
287
  else:
288
  non_capturing -= 1
@@ -291,138 +293,173 @@ def get_is_piece_sacrifice(board: chess.Board, played_move: chess.Move, best_pv:
291
  sim_board.push(m)
292
  else:
293
  break
294
-
295
  for p in captured_w[:]:
296
  if p in captured_b:
297
  captured_w.remove(p)
298
  captured_b.remove(p)
299
-
300
  if abs(len(captured_w) - len(captured_b)) <= 1 and all(p == chess.PAWN for p in captured_w + captured_b):
301
  return False
302
-
303
  end_diff = get_material_difference(sim_board)
304
- mat_diff = end_diff - start_diff
305
- player_rel = mat_diff if white_to_play else -mat_diff
306
-
307
- return player_rel < 0
308
 
309
  def get_move_classification(
310
- last_win_pct: float,
311
- pos_win_pct: float,
312
- is_white_move: bool,
313
- played_move: chess.Move,
314
- best_move_before: chess.Move,
315
- alt_win_pct: Optional[float],
316
- fen_two_moves_ago: Optional[str],
317
- uci_next_two_moves: Optional[Tuple[chess.Move, chess.Move]],
318
- board_before_move: chess.Board,
319
- best_pv_after: list
320
  ) -> str:
321
  diff = (pos_win_pct - last_win_pct) * (1 if is_white_move else -1)
322
-
323
  if alt_win_pct is not None and diff >= -2.0:
324
  if get_is_piece_sacrifice(board_before_move, played_move, best_pv_after):
325
  if not is_losing_or_alt_winning(pos_win_pct, alt_win_pct, is_white_move):
326
  return "Brilliant"
327
-
328
  if alt_win_pct is not None and diff >= -2.0:
329
  is_recapture = False
330
  if fen_two_moves_ago and uci_next_two_moves:
331
- is_recapture = is_simple_recapture(fen_two_moves_ago, uci_next_two_moves[0], uci_next_two_moves[1])
332
-
333
  if not is_recapture and not is_losing_or_alt_winning(pos_win_pct, alt_win_pct, is_white_move):
334
- if get_has_changed_outcome(last_win_pct, pos_win_pct, is_white_move) or get_is_only_good_move(pos_win_pct, alt_win_pct, is_white_move):
 
335
  return "Great"
336
-
337
  if best_move_before and played_move == best_move_before:
338
  return "Best"
339
-
340
  if diff < -20.0: return "Blunder"
341
  if diff < -10.0: return "Mistake"
342
- if diff < -5.0: return "Inaccuracy"
343
- if diff < -2.0: return "Good"
344
  return "Excellent"
345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  @app.post("/analyze-game", response_model=AnalyzeResponse)
347
  async def analyze_game(request: AnalyzeRequest):
348
- engine = None
349
- try:
350
- engine = await get_engine()
351
  board = chess.Board(request.start_fen) if request.start_fen else chess.Board()
352
  limit = chess.engine.Limit(time=request.time_per_move)
353
-
354
  analysis_results = []
355
-
356
- infos_before = await engine.analyse(board, limit, multipv=2)
357
- infos_before = infos_before if isinstance(infos_before, list) else [infos_before]
358
-
359
  counts = {
360
- "Book": 0, "Brilliant": 0, "Great": 0, "Best": 0,
361
- "Excellent": 0, "Good": 0, "Inaccuracy": 0,
362
  "Mistake": 0, "Blunder": 0
363
  }
364
 
365
- player_is_white = (request.player_color.lower() == "white")
366
-
367
- fen_history = [board.fen()]
368
- move_history = []
369
- total_cpl = 0.0
370
  player_moves_count = 0
 
 
 
 
371
  current_score, _ = get_normalized_score(infos_before[0])
372
 
373
  for i, san_move in enumerate(request.moves):
374
- is_white_turn = board.turn == chess.WHITE
375
  is_player_turn = is_white_turn if player_is_white else not is_white_turn
376
-
377
- score_before = current_score
378
-
379
- try:
380
- move = board.parse_san(san_move)
381
- except Exception:
382
- break # Invalid move
383
 
384
- info_dict = infos_before[0]
385
- pv_list = info_dict.get("pv", [])
386
  best_move_before = pv_list[0] if pv_list else None
387
-
388
  score_before, _ = get_normalized_score(info_dict)
389
- win_pct_before = get_win_percentage(info_dict)
390
  alt_win_pct_before: Optional[float] = None
391
- if len(infos_before) > 1:
392
- # Find the first alternative move that is not the played move
393
- for line in infos_before:
394
- if line.get("pv") and line.get("pv")[0] != move:
395
- alt_win_pct_before = get_win_percentage(line)
396
- break
 
 
 
 
 
397
 
398
  board_before_move = board.copy()
399
  board.push(move)
400
-
401
  move_history.append(move)
402
  fen_history.append(board.fen())
403
-
404
  infos_after_raw = await engine.analyse(board, limit, multipv=2)
405
  infos_after: List[dict] = infos_after_raw if isinstance(infos_after_raw, list) else [infos_after_raw]
406
-
407
- info_after_dict: dict = infos_after[0]
408
-
409
- win_pct_after = get_win_percentage(info_after_dict)
410
- score_after, _ = get_normalized_score(info_after_dict)
411
- current_score = score_after
412
-
413
- best_pv_after = info_after_dict.get("pv", [])
414
-
415
- fen_two_moves_ago = None
416
  uci_next_two_moves = None
417
  if len(move_history) >= 2:
418
- fen_two_moves_ago = fen_history[-3]
419
  uci_next_two_moves = (move_history[-2], move_history[-1])
420
 
421
- cls = "Book"
422
- opening_name = None
423
  board_fen_only = board.fen().split(" ")[0]
424
  if board_fen_only in openings_db:
425
- cls = "Book"
426
  opening_name = openings_db[board_fen_only]
427
  else:
428
  cls = get_move_classification(
@@ -435,63 +472,50 @@ async def analyze_game(request: AnalyzeRequest):
435
  fen_two_moves_ago=fen_two_moves_ago,
436
  uci_next_two_moves=uci_next_two_moves,
437
  board_before_move=board_before_move,
438
- best_pv_after=best_pv_after
439
  )
440
-
 
441
  move_gain = score_after - score_before if is_white_turn else score_before - score_after
442
- cpl = max(0, -move_gain)
443
- cpl = min(cpl, 1000.0)
444
-
445
  if is_player_turn:
446
- total_cpl += cpl
447
  player_moves_count += 1
448
- counts[cls] = counts.get(cls, 0) + 1
449
-
450
  analysis_results.append(MoveAnalysis(
451
- move_num=i+1,
452
  san=san_move,
453
- fen=board.fen(),
454
  classification=cls,
455
  cpl=float(cpl),
456
  score_before=float(score_before / 100.0),
457
  score_after=float(score_after / 100.0),
458
  best_move=best_move_before.uci() if best_move_before else "",
459
- opening=opening_name
460
  ))
461
-
462
  infos_before = infos_after
463
 
464
- # Win probability matching accuracy formula
465
- # Accuracy = 100 * exp(-0.02 * avg_cpl) smoothed
466
- avg_cpl = total_cpl / max(1, player_moves_count)
467
-
468
- # Simple heuristic mapping for Accuracy & Elo
469
- # 0 avg loss -> 100%
470
- # ~100 avg loss -> ~60%
471
- accuracy = max(10.0, min(100.0, 100.0 * math.exp(-0.005 * avg_cpl)))
472
-
473
- # Exponential Elo Decay calibrated to 3600 max engine strength
474
  estimated_elo = int(max(400, min(3600, round(3600 * math.exp(-0.015 * avg_cpl)))))
475
 
476
  return AnalyzeResponse(
477
  accuracy=round(accuracy, 1),
478
  estimated_elo=estimated_elo,
479
  moves=analysis_results,
480
- counts=counts
481
  )
482
-
483
- except Exception as e:
484
- print(f"Analysis Error: {e}")
485
- raise HTTPException(status_code=500, detail=str(e))
486
- finally:
487
- if engine:
488
- try:
489
- await engine.quit()
490
- except Exception:
491
- pass
492
 
493
 
494
  if __name__ == "__main__":
495
  import uvicorn
496
- # Hugging Face Spaces port is 7860
497
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
  from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from pydantic import BaseModel
 
10
  import asyncio
11
  import json
12
 
13
+ # ─── Engine Pool ───────────────────────────────────────────────────────────────
14
+ ENGINE_PATH = os.environ.get("ENGINE_PATH", "/app/engine/deepcastle")
15
+ NNUE_PATH = os.environ.get("NNUE_PATH", "/app/engine/output.nnue")
16
+
17
+ POOL_SIZE = 4
18
+
19
+ class EnginePool:
20
+ def __init__(self):
21
+ self._queue: asyncio.Queue = asyncio.Queue()
22
+ self._all_engines: list = []
23
+
24
+ async def start(self):
25
+ for i in range(POOL_SIZE):
26
+ engine = await self._spawn()
27
+ self._all_engines.append(engine)
28
+ await self._queue.put(engine)
29
+ print(f"[Pool] {POOL_SIZE} engines ready")
30
+
31
+ async def _spawn(self):
32
+ transport, engine = await chess.engine.popen_uci(ENGINE_PATH)
33
+ options = {"Threads": 2, "Hash": 512}
34
+ # Try each NNUE candidate in order
35
+ for candidate in [NNUE_PATH,
36
+ "/app/engine/custom_big.nnue",
37
+ "/app/engine/nn-9a0cc2a62c52.nnue",
38
+ "/app/engine/nn-47fc8b7fff06.nnue"]:
39
+ if os.path.exists(candidate):
40
+ options["EvalFile"] = candidate
41
+ break
42
+ try:
43
+ await engine.configure(options)
44
+ except Exception as e:
45
+ print(f"[Pool] configure warning: {e}")
46
+ return engine
47
+
48
+ @asynccontextmanager
49
+ async def acquire(self, timeout: float = 10.0):
50
+ try:
51
+ engine = await asyncio.wait_for(self._queue.get(), timeout=timeout)
52
+ except asyncio.TimeoutError:
53
+ raise HTTPException(status_code=503,
54
+ detail="All engines busy — try again shortly")
55
+ healthy = True
56
+ try:
57
+ yield engine
58
+ except Exception:
59
+ healthy = False
60
+ raise
61
+ finally:
62
+ if healthy:
63
+ await self._queue.put(engine)
64
+ else:
65
+ try:
66
+ await engine.quit()
67
+ except Exception:
68
+ pass
69
+ try:
70
+ fresh = await self._spawn()
71
+ await self._queue.put(fresh)
72
+ print("[Pool] replaced dead engine")
73
+ except Exception as e:
74
+ print(f"[Pool] CRITICAL: could not replace engine: {e}")
75
+
76
+ async def shutdown(self):
77
+ engines = []
78
+ while not self._queue.empty():
79
+ try:
80
+ engines.append(self._queue.get_nowait())
81
+ except asyncio.QueueEmpty:
82
+ break
83
+ for e in engines:
84
+ try:
85
+ await e.quit()
86
+ except Exception:
87
+ pass
88
+ print("[Pool] all engines shut down")
89
+
90
+
91
+ pool = EnginePool()
92
+
93
+
94
+ # ─── Openings DB (loaded once at startup) ─────────────────────────────────────
95
+ openings_db: dict = {}
96
 
97
+ def load_openings():
98
+ global openings_db
99
+ path = os.path.join(os.path.dirname(__file__), "openings.json")
100
+ if os.path.exists(path):
101
+ try:
102
+ with open(path, "r", encoding="utf-8") as f:
103
+ openings_db = json.load(f)
104
+ print(f"[Openings] Loaded {len(openings_db)} positions")
105
+ except Exception as e:
106
+ print(f"[Openings] Load error: {e}")
107
+
108
+
109
+ # ─── FastAPI Lifespan ─────────────────────────────────────────────────────────
110
+ @asynccontextmanager
111
+ async def lifespan(app: FastAPI):
112
+ load_openings()
113
+ await pool.start()
114
+ yield
115
+ await pool.shutdown()
116
+
117
+ app = FastAPI(title="Deepcastle Engine API", lifespan=lifespan)
118
+
119
+
120
+ # ─── CORS ─────────────────────────────────────────────────────────────────────
121
+ app.add_middleware(
122
+ CORSMiddleware,
123
+ allow_origins=["*"],
124
+ allow_methods=["*"],
125
+ allow_headers=["*"],
126
+ )
127
+
128
+
129
+ # ─── WebSocket / Multiplayer ──────────────────────────────────────────────────
130
  class ConnectionManager:
131
  def __init__(self):
 
132
  self.active_connections: Dict[str, List[WebSocket]] = {}
133
 
134
  async def connect(self, websocket: WebSocket, match_id: str):
135
  await websocket.accept()
136
+ self.active_connections.setdefault(match_id, []).append(websocket)
 
 
137
 
138
  def disconnect(self, websocket: WebSocket, match_id: str):
139
  if match_id in self.active_connections:
140
+ self.active_connections[match_id].discard(websocket) \
141
+ if hasattr(self.active_connections[match_id], "discard") \
142
+ else self._safe_remove(match_id, websocket)
143
  if not self.active_connections[match_id]:
144
  del self.active_connections[match_id]
145
 
146
+ def _safe_remove(self, match_id: str, ws: WebSocket):
147
+ try:
148
+ self.active_connections[match_id].remove(ws)
149
+ except ValueError:
150
+ pass
151
+
152
  async def broadcast(self, message: str, match_id: str, exclude: WebSocket = None):
153
+ for conn in self.active_connections.get(match_id, []):
154
+ if conn != exclude:
155
+ try:
156
+ await conn.send_text(message)
157
+ except Exception:
158
+ pass
 
159
 
160
  manager = ConnectionManager()
161
 
162
  @app.websocket("/ws/{match_id}")
163
  async def websocket_endpoint(websocket: WebSocket, match_id: str):
164
  await manager.connect(websocket, match_id)
 
 
165
  await manager.broadcast(json.dumps({"type": "join"}), match_id, exclude=websocket)
166
  try:
167
  while True:
168
  data = await websocket.receive_text()
 
169
  await manager.broadcast(data, match_id, exclude=websocket)
170
  except WebSocketDisconnect:
171
  manager.disconnect(websocket, match_id)
 
172
  await manager.broadcast(json.dumps({"type": "opponent_disconnected"}), match_id)
173
  except Exception:
174
  manager.disconnect(websocket, match_id)
175
  await manager.broadcast(json.dumps({"type": "opponent_disconnected"}), match_id)
176
 
177
 
178
+ # ─── Pydantic Models ──────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
179
  class MoveRequest(BaseModel):
180
  fen: str
181
+ time: float = 1.0
182
  depth: Optional[int] = None
183
 
184
  class MoveResponse(BaseModel):
 
213
  moves: List[MoveAnalysis]
214
  counts: Dict[str, int]
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
+ # ─── Score Utilities ──────────────────────────────────────────────────────────
218
  def get_normalized_score(info) -> tuple[float, Optional[int]]:
 
219
  if "score" not in info:
220
  return 0.0, None
221
  raw = info["score"].white()
 
224
  return (10000.0 if m > 0 else -10000.0), m
225
  return raw.score() or 0.0, None
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  def get_win_percentage_from_cp(cp: int) -> float:
228
  cp_ceiled = max(-1000, min(1000, cp))
229
  MULTIPLIER = -0.00368208
 
236
  return 50.0
237
  white_score = score.white()
238
  if white_score.is_mate():
239
+ return 100.0 if white_score.mate() > 0 else 0.0
 
240
  return get_win_percentage_from_cp(white_score.score())
241
 
242
  def is_losing_or_alt_winning(pos_win_pct: float, alt_win_pct: float, is_white_move: bool) -> bool:
243
+ is_losing = pos_win_pct < 50.0 if is_white_move else pos_win_pct > 50.0
244
  is_alt_winning = alt_win_pct > 97.0 if is_white_move else alt_win_pct < 3.0
245
  return is_losing or is_alt_winning
246
 
247
  def get_has_changed_outcome(last_win_pct: float, pos_win_pct: float, is_white_move: bool) -> bool:
248
  diff = (pos_win_pct - last_win_pct) * (1 if is_white_move else -1)
249
+ return diff > 10.0 and (
250
+ (last_win_pct < 50.0 and pos_win_pct > 50.0) or
251
+ (last_win_pct > 50.0 and pos_win_pct < 50.0)
252
+ )
253
 
254
  def get_is_only_good_move(pos_win_pct: float, alt_win_pct: float, is_white_move: bool) -> bool:
255
  diff = (pos_win_pct - alt_win_pct) * (1 if is_white_move else -1)
 
262
  return b.piece_at(previous_move.to_square) is not None
263
 
264
  def get_material_difference(board: chess.Board) -> int:
265
+ values = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3,
266
+ chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 0}
267
  w = sum(values.get(p.piece_type, 0) for p in board.piece_map().values() if p.color == chess.WHITE)
268
  b = sum(values.get(p.piece_type, 0) for p in board.piece_map().values() if p.color == chess.BLACK)
269
  return w - b
 
271
  def get_is_piece_sacrifice(board: chess.Board, played_move: chess.Move, best_pv: list) -> bool:
272
  if not best_pv:
273
  return False
 
274
  white_to_play = board.turn == chess.WHITE
 
275
  sim_board = board.copy()
276
  moves = [played_move] + best_pv
277
  if len(moves) % 2 == 1:
278
  moves = moves[:-1]
279
+ captured_w, captured_b = [], []
 
 
280
  non_capturing = 1
 
281
  for m in moves:
282
  if m in sim_board.legal_moves:
283
  captured_piece = sim_board.piece_at(m.to_square)
284
  if sim_board.is_en_passant(m):
285
  captured_piece = chess.Piece(chess.PAWN, not sim_board.turn)
 
286
  if captured_piece:
287
+ (captured_b if sim_board.turn == chess.WHITE else captured_w).append(captured_piece.piece_type)
 
 
 
288
  non_capturing = 1
289
  else:
290
  non_capturing -= 1
 
293
  sim_board.push(m)
294
  else:
295
  break
 
296
  for p in captured_w[:]:
297
  if p in captured_b:
298
  captured_w.remove(p)
299
  captured_b.remove(p)
 
300
  if abs(len(captured_w) - len(captured_b)) <= 1 and all(p == chess.PAWN for p in captured_w + captured_b):
301
  return False
 
302
  end_diff = get_material_difference(sim_board)
303
+ mat_diff = end_diff - get_material_difference(board)
304
+ return (mat_diff if white_to_play else -mat_diff) < 0
 
 
305
 
306
  def get_move_classification(
307
+ last_win_pct, pos_win_pct, is_white_move,
308
+ played_move, best_move_before, alt_win_pct,
309
+ fen_two_moves_ago, uci_next_two_moves,
310
+ board_before_move, best_pv_after
 
 
 
 
 
 
311
  ) -> str:
312
  diff = (pos_win_pct - last_win_pct) * (1 if is_white_move else -1)
 
313
  if alt_win_pct is not None and diff >= -2.0:
314
  if get_is_piece_sacrifice(board_before_move, played_move, best_pv_after):
315
  if not is_losing_or_alt_winning(pos_win_pct, alt_win_pct, is_white_move):
316
  return "Brilliant"
 
317
  if alt_win_pct is not None and diff >= -2.0:
318
  is_recapture = False
319
  if fen_two_moves_ago and uci_next_two_moves:
320
+ is_recapture = is_simple_recapture(fen_two_moves_ago, *uci_next_two_moves)
 
321
  if not is_recapture and not is_losing_or_alt_winning(pos_win_pct, alt_win_pct, is_white_move):
322
+ if get_has_changed_outcome(last_win_pct, pos_win_pct, is_white_move) or \
323
+ get_is_only_good_move(pos_win_pct, alt_win_pct, is_white_move):
324
  return "Great"
 
325
  if best_move_before and played_move == best_move_before:
326
  return "Best"
 
327
  if diff < -20.0: return "Blunder"
328
  if diff < -10.0: return "Mistake"
329
+ if diff < -5.0: return "Inaccuracy"
330
+ if diff < -2.0: return "Good"
331
  return "Excellent"
332
 
333
+
334
+ # ─── Routes ───────────────────────────────────────────────────────────────────
335
+ @app.get("/")
336
+ def home():
337
+ return {"status": "online", "engine": "Deepcastle Hybrid Neural",
338
+ "platform": "Hugging Face Spaces", "pool_size": POOL_SIZE}
339
+
340
+ @app.get("/health")
341
+ def health():
342
+ if not os.path.exists(ENGINE_PATH):
343
+ return {"status": "error", "message": "Engine binary not found"}
344
+ return {"status": "ok", "engine": "Deepcastle", "pool_size": POOL_SIZE}
345
+
346
+ @app.get("/pool-status")
347
+ def pool_status():
348
+ """How many engines are currently idle."""
349
+ return {"idle_engines": pool._queue.qsize(), "total_engines": POOL_SIZE}
350
+
351
+
352
+ @app.post("/move", response_model=MoveResponse)
353
+ async def get_move(request: MoveRequest):
354
+ async with pool.acquire(timeout=10.0) as engine:
355
+ board = chess.Board(request.fen)
356
+ limit = chess.engine.Limit(time=request.time, depth=request.depth)
357
+
358
+ result = await engine.play(board, limit)
359
+ info = await engine.analyse(board, limit)
360
+
361
+ score_cp, mate_in = get_normalized_score(info)
362
+ depth = info.get("depth", 0)
363
+ nodes = info.get("nodes", 0)
364
+ nps = info.get("nps", 0)
365
+
366
+ pv_board, pv_parts = board.copy(), []
367
+ for m in info.get("pv", [])[:5]:
368
+ if m in pv_board.legal_moves:
369
+ try:
370
+ pv_parts.append(pv_board.san(m))
371
+ pv_board.push(m)
372
+ except Exception:
373
+ break
374
+ else:
375
+ break
376
+
377
+ score_pawns = score_cp / 100.0 if abs(score_cp) < 9900 else (100.0 if score_cp > 0 else -100.0)
378
+ board_fen_only = board.fen().split(" ")[0]
379
+
380
+ return MoveResponse(
381
+ bestmove=result.move.uci(),
382
+ score=score_pawns,
383
+ depth=depth,
384
+ nodes=nodes,
385
+ nps=nps,
386
+ pv=" ".join(pv_parts),
387
+ mate_in=mate_in,
388
+ opening=openings_db.get(board_fen_only),
389
+ )
390
+
391
+
392
  @app.post("/analyze-game", response_model=AnalyzeResponse)
393
  async def analyze_game(request: AnalyzeRequest):
394
+ # Analysis holds ONE engine for the whole game — intentional.
395
+ async with pool.acquire(timeout=30.0) as engine:
 
396
  board = chess.Board(request.start_fen) if request.start_fen else chess.Board()
397
  limit = chess.engine.Limit(time=request.time_per_move)
398
+
399
  analysis_results = []
 
 
 
 
400
  counts = {
401
+ "Book": 0, "Brilliant": 0, "Great": 0, "Best": 0,
402
+ "Excellent": 0, "Good": 0, "Inaccuracy": 0,
403
  "Mistake": 0, "Blunder": 0
404
  }
405
 
406
+ player_is_white = request.player_color.lower() == "white"
407
+ fen_history = [board.fen()]
408
+ move_history = []
409
+ total_cpl = 0.0
 
410
  player_moves_count = 0
411
+
412
+ infos_before = await engine.analyse(board, limit, multipv=2)
413
+ if not isinstance(infos_before, list):
414
+ infos_before = [infos_before]
415
  current_score, _ = get_normalized_score(infos_before[0])
416
 
417
  for i, san_move in enumerate(request.moves):
418
+ is_white_turn = board.turn == chess.WHITE
419
  is_player_turn = is_white_turn if player_is_white else not is_white_turn
 
 
 
 
 
 
 
420
 
421
+ info_dict = infos_before[0]
422
+ pv_list = info_dict.get("pv", [])
423
  best_move_before = pv_list[0] if pv_list else None
424
+
425
  score_before, _ = get_normalized_score(info_dict)
426
+ win_pct_before = get_win_percentage(info_dict)
427
  alt_win_pct_before: Optional[float] = None
428
+ for line in infos_before:
429
+ if line.get("pv") and line.get("pv")[0] != (
430
+ board.parse_san(san_move) if san_move else None
431
+ ):
432
+ alt_win_pct_before = get_win_percentage(line)
433
+ break
434
+
435
+ try:
436
+ move = board.parse_san(san_move)
437
+ except Exception:
438
+ break
439
 
440
  board_before_move = board.copy()
441
  board.push(move)
 
442
  move_history.append(move)
443
  fen_history.append(board.fen())
444
+
445
  infos_after_raw = await engine.analyse(board, limit, multipv=2)
446
  infos_after: List[dict] = infos_after_raw if isinstance(infos_after_raw, list) else [infos_after_raw]
447
+ info_after_dict = infos_after[0]
448
+
449
+ win_pct_after = get_win_percentage(info_after_dict)
450
+ score_after, _ = get_normalized_score(info_after_dict)
451
+ current_score = score_after
452
+ best_pv_after = info_after_dict.get("pv", [])
453
+
454
+ fen_two_moves_ago = None
 
 
455
  uci_next_two_moves = None
456
  if len(move_history) >= 2:
457
+ fen_two_moves_ago = fen_history[-3]
458
  uci_next_two_moves = (move_history[-2], move_history[-1])
459
 
 
 
460
  board_fen_only = board.fen().split(" ")[0]
461
  if board_fen_only in openings_db:
462
+ cls = "Book"
463
  opening_name = openings_db[board_fen_only]
464
  else:
465
  cls = get_move_classification(
 
472
  fen_two_moves_ago=fen_two_moves_ago,
473
  uci_next_two_moves=uci_next_two_moves,
474
  board_before_move=board_before_move,
475
+ best_pv_after=best_pv_after,
476
  )
477
+ opening_name = None
478
+
479
  move_gain = score_after - score_before if is_white_turn else score_before - score_after
480
+ cpl = min(max(0.0, -move_gain), 1000.0)
481
+
 
482
  if is_player_turn:
483
+ total_cpl += cpl
484
  player_moves_count += 1
485
+ counts[cls] = counts.get(cls, 0) + 1
486
+
487
  analysis_results.append(MoveAnalysis(
488
+ move_num=i + 1,
489
  san=san_move,
 
490
  classification=cls,
491
  cpl=float(cpl),
492
  score_before=float(score_before / 100.0),
493
  score_after=float(score_after / 100.0),
494
  best_move=best_move_before.uci() if best_move_before else "",
495
+ opening=opening_name,
496
  ))
497
+
498
  infos_before = infos_after
499
 
500
+ avg_cpl = total_cpl / max(1, player_moves_count)
501
+ accuracy = max(10.0, min(100.0, 100.0 * math.exp(-0.005 * avg_cpl)))
 
 
 
 
 
 
 
 
502
  estimated_elo = int(max(400, min(3600, round(3600 * math.exp(-0.015 * avg_cpl)))))
503
 
504
  return AnalyzeResponse(
505
  accuracy=round(accuracy, 1),
506
  estimated_elo=estimated_elo,
507
  moves=analysis_results,
508
+ counts=counts,
509
  )
 
 
 
 
 
 
 
 
 
 
510
 
511
 
512
  if __name__ == "__main__":
513
  import uvicorn
514
+ uvicorn.run(
515
+ "main:app",
516
+ host="0.0.0.0",
517
+ port=7860,
518
+ workers=1, # Must be 1 — the pool lives in-process
519
+ loop="uvloop", # Faster event loop
520
+ log_level="info",
521
+ )