Amogh1221 commited on
Commit
a300882
Β·
verified Β·
1 Parent(s): 5692419

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +196 -115
main.py CHANGED
@@ -1,7 +1,8 @@
1
- from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
2
  from fastapi.middleware.cors import CORSMiddleware
 
3
  from pydantic import BaseModel
4
- from typing import Optional, List, Dict
5
  from contextlib import asynccontextmanager
6
  import os
7
  import math
@@ -9,11 +10,11 @@ import chess
9
  import chess.engine
10
  import asyncio
11
  import json
 
12
 
13
- # ─── Multiplaying / Challenge Manager ───────────────────────────────────────
14
  class ConnectionManager:
15
  def __init__(self):
16
- # match_id -> list of websockets
17
  self.active_connections: Dict[str, List[WebSocket]] = {}
18
 
19
  async def connect(self, websocket: WebSocket, match_id: str):
@@ -26,17 +27,28 @@ class ConnectionManager:
26
  if match_id in self.active_connections:
27
  if websocket in self.active_connections[match_id]:
28
  self.active_connections[match_id].remove(websocket)
 
29
  if not self.active_connections[match_id]:
30
  del self.active_connections[match_id]
31
 
32
  async def broadcast(self, message: str, match_id: str, exclude: WebSocket = None):
33
- if match_id in self.active_connections:
34
- for connection in self.active_connections[match_id]:
35
- if connection != exclude:
36
- try:
37
- await connection.send_text(message)
38
- except Exception:
39
- pass
 
 
 
 
 
 
 
 
 
 
40
 
41
  manager = ConnectionManager()
42
 
@@ -48,9 +60,10 @@ DEEPCASTLE_ENGINE_PATH = os.environ.get(
48
  NNUE_PATH = os.environ.get("NNUE_PATH", "/app/engine_bin/output.nnue")
49
  NNUE_SMALL_PATH = os.environ.get("NNUE_SMALL_PATH", "/app/engine_bin/small_output.nnue")
50
 
 
51
  class MoveRequest(BaseModel):
52
  fen: str
53
- time: float = 1.0 # seconds
54
  depth: Optional[int] = None
55
 
56
  class MoveResponse(BaseModel):
@@ -85,27 +98,24 @@ class AnalyzeResponse(BaseModel):
85
  moves: List[MoveAnalysis]
86
  counts: Dict[str, int]
87
 
88
- # Global engine instances to save memory and improve performance
 
89
  _GLOBAL_DEEPCASTLE_ENGINE = None
90
  _ENGINE_LOCK = asyncio.Lock()
91
- # UCI engines handle one search at a time; concurrent play/analyse on the same process
92
- # corrupts the protocol and can crash the binary β€” serialize all I/O.
93
  _ENGINE_IO_LOCK = asyncio.Lock()
94
 
95
 
96
  def _engine_hash_mb() -> int:
97
- """Transposition table size in MB; lower = less RAM (env ENGINE_HASH_MB, default 64)."""
98
  try:
99
- v = int(os.environ.get("ENGINE_HASH_MB", "64"))
100
  except ValueError:
101
- v = 64
102
  return max(8, min(512, v))
103
 
104
 
105
  async def _get_or_start_engine(engine_path: str, *, role: str, options: Optional[dict] = None):
106
  global _GLOBAL_DEEPCASTLE_ENGINE
107
 
108
- # Fast path: no lock when singleton is already healthy (avoids serializing every /move).
109
  current_engine = _GLOBAL_DEEPCASTLE_ENGINE
110
  if current_engine is not None:
111
  try:
@@ -153,7 +163,6 @@ async def _get_or_start_engine(engine_path: str, *, role: str, options: Optional
153
  print(f"[WARNING] EvalFileSmall not found at {NNUE_SMALL_PATH}")
154
 
155
  _GLOBAL_DEEPCASTLE_ENGINE = engine
156
-
157
  return engine
158
  except Exception as e:
159
  raise HTTPException(status_code=500, detail=f"{role} crash: {str(e)}")
@@ -167,12 +176,19 @@ async def get_deepcastle_engine():
167
  )
168
 
169
  async def get_stockfish_engine():
170
- # Compatibility alias: analysis now also uses DeepCastle.
171
  return await get_deepcastle_engine()
172
 
173
 
 
 
 
 
 
 
 
 
 
174
  async def shutdown_engine_async() -> None:
175
- """Release UCI subprocess on process exit (deploy / SIGTERM)."""
176
  global _GLOBAL_DEEPCASTLE_ENGINE
177
  async with _ENGINE_IO_LOCK:
178
  async with _ENGINE_LOCK:
@@ -186,7 +202,6 @@ async def shutdown_engine_async() -> None:
186
 
187
 
188
  async def _detach_and_quit_engine(engine) -> None:
189
- """After a hung search, drop the singleton and try to terminate the process."""
190
  global _GLOBAL_DEEPCASTLE_ENGINE
191
  async with _ENGINE_LOCK:
192
  if _GLOBAL_DEEPCASTLE_ENGINE is engine:
@@ -198,7 +213,6 @@ async def _detach_and_quit_engine(engine) -> None:
198
 
199
 
200
  def _search_timeout_sec(request_time: float, depth: Optional[int] = None) -> float:
201
- """Wall-clock cap for a single play/analyse (env ENGINE_SEARCH_TIMEOUT_SEC, default 120)."""
202
  try:
203
  cap = float(os.environ.get("ENGINE_SEARCH_TIMEOUT_SEC", "120"))
204
  except ValueError:
@@ -210,7 +224,6 @@ def _search_timeout_sec(request_time: float, depth: Optional[int] = None) -> flo
210
 
211
 
212
  def _analyze_ply_timeout(time_per_move: float) -> float:
213
- """Wall-clock cap per analyse() in /analyze-game (multipv=2 needs headroom)."""
214
  try:
215
  cap = float(os.environ.get("ENGINE_SEARCH_TIMEOUT_SEC", "120"))
216
  except ValueError:
@@ -237,7 +250,14 @@ async def lifespan(app: FastAPI):
237
 
238
  app = FastAPI(title="Deepcastle Engine API", lifespan=lifespan)
239
 
240
- # Allow ALL for easy testing (we can restrict this later if needed)
 
 
 
 
 
 
 
241
  app.add_middleware(
242
  CORSMiddleware,
243
  allow_origins=["*"],
@@ -246,10 +266,10 @@ app.add_middleware(
246
  )
247
 
248
 
 
249
  @app.websocket("/ws/{match_id}")
250
  async def websocket_endpoint(websocket: WebSocket, match_id: str):
251
  await manager.connect(websocket, match_id)
252
- room = manager.active_connections.get(match_id, [])
253
  await manager.broadcast(json.dumps({"type": "join"}), match_id, exclude=websocket)
254
  try:
255
  while True:
@@ -257,27 +277,33 @@ async def websocket_endpoint(websocket: WebSocket, match_id: str):
257
  await manager.broadcast(data, match_id, exclude=websocket)
258
  except WebSocketDisconnect:
259
  manager.disconnect(websocket, match_id)
 
260
  await manager.broadcast(json.dumps({"type": "opponent_disconnected"}), match_id)
 
261
  except Exception:
262
  manager.disconnect(websocket, match_id)
263
  await manager.broadcast(json.dumps({"type": "opponent_disconnected"}), match_id)
 
264
 
265
 
 
266
  @app.get("/")
267
  def home():
268
  return {"status": "online", "engine": "Deepcastle Hybrid Neural", "platform": "Hugging Face Spaces"}
269
 
270
 
 
271
  @app.api_route("/health", methods=["GET", "HEAD"])
272
  def health():
273
  if not os.path.exists(DEEPCASTLE_ENGINE_PATH):
274
  return {"status": "error", "message": "Missing engine binary: deepcastle"}
 
 
275
  return {"status": "ok", "engine": "deepcastle"}
276
 
277
 
278
  @app.get("/health/ready")
279
  async def health_ready():
280
- """Optional deep check: binary exists and engine answers UCI (for orchestrators)."""
281
  if not os.path.exists(DEEPCASTLE_ENGINE_PATH):
282
  raise HTTPException(status_code=503, detail="Missing engine binary")
283
  try:
@@ -291,8 +317,31 @@ async def health_ready():
291
  raise HTTPException(status_code=503, detail=str(e))
292
 
293
 
294
- def get_normalized_score(info) -> tuple[float, Optional[int]]:
295
- """Returns the score from White's perspective in centipawns."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  if "score" not in info:
297
  return 0.0, None
298
  raw = info["score"].white()
@@ -302,12 +351,7 @@ def get_normalized_score(info) -> tuple[float, Optional[int]]:
302
  return float(raw.score() or 0.0), None
303
 
304
 
305
- def normalize_search_stats(info: dict) -> tuple[int, int, int]:
306
- """
307
- Depth, nodes, and NPS from a single search. Prefer NPS = nodes / elapsed time
308
- when the engine reports time so the UI stays internally consistent (UCI nps is
309
- often an instantaneous rate and need not match total nodes).
310
- """
311
  depth = int(info.get("depth") or 0)
312
  nodes = int(info.get("nodes") or 0)
313
  t = info.get("time")
@@ -319,16 +363,15 @@ def normalize_search_stats(info: dict) -> tuple[int, int, int]:
319
  return depth, nodes, nps
320
 
321
 
322
- # ─── Engine Inference Route ────────────────────────────────────────────────────
323
  @app.post("/move", response_model=MoveResponse)
324
  async def get_move(request: MoveRequest):
325
  try:
326
  engine = await get_deepcastle_engine()
327
  board = chess.Board(request.fen)
328
  limit = chess.engine.Limit(time=request.time, depth=request.depth)
329
-
330
- # One search: stats must come from this run (not a separate short analyse).
331
  tsec = _search_timeout_sec(request.time, request.depth)
 
332
  async with _ENGINE_IO_LOCK:
333
  result = await _engine_call(
334
  engine,
@@ -342,12 +385,11 @@ async def get_move(request: MoveRequest):
342
  engine.analyse(board, limit, info=chess.engine.INFO_ALL),
343
  tsec,
344
  )
345
-
346
- # From White's perspective in CP -> converted to Pawns for UI
347
  score_cp, mate_in = get_normalized_score(info)
348
-
349
  depth, nodes, nps = normalize_search_stats(info)
350
 
 
351
  pv_board = board.copy()
352
  pv_parts = []
353
  for m in info.get("pv", [])[:5]:
@@ -360,16 +402,20 @@ async def get_move(request: MoveRequest):
360
  else:
361
  break
362
  pv = " ".join(pv_parts)
 
363
 
364
- # Map mate score to pawns representation to not break old UI
365
  score_pawns = score_cp / 100.0 if abs(score_cp) < 9900 else (100.0 if score_cp > 0 else -100.0)
366
 
367
- # Check for opening name
368
  board_fen_only = board.fen().split(" ")[0]
369
  opening_name = openings_db.get(board_fen_only)
 
 
 
 
 
370
 
371
  return MoveResponse(
372
- bestmove=result.move.uci(),
373
  score=score_pawns,
374
  depth=depth,
375
  nodes=nodes,
@@ -384,14 +430,16 @@ async def get_move(request: MoveRequest):
384
  print(f"Error: {e}")
385
  raise HTTPException(status_code=500, detail=str(e))
386
 
 
 
387
  @app.post("/analysis-move", response_model=MoveResponse)
388
  async def get_analysis_move(request: MoveRequest):
389
  try:
390
  engine = await get_stockfish_engine()
391
  board = chess.Board(request.fen)
392
  limit = chess.engine.Limit(time=request.time, depth=request.depth)
393
-
394
  tsec = _search_timeout_sec(request.time, request.depth)
 
395
  async with _ENGINE_IO_LOCK:
396
  result = await _engine_call(
397
  engine,
@@ -407,9 +455,9 @@ async def get_analysis_move(request: MoveRequest):
407
  )
408
 
409
  score_cp, mate_in = get_normalized_score(info)
410
-
411
  depth, nodes, nps = normalize_search_stats(info)
412
 
 
413
  pv_board = board.copy()
414
  pv_parts = []
415
  for m in info.get("pv", [])[:5]:
@@ -422,14 +470,25 @@ async def get_analysis_move(request: MoveRequest):
422
  else:
423
  break
424
  pv = " ".join(pv_parts)
 
425
 
426
  score_pawns = score_cp / 100.0 if abs(score_cp) < 9900 else (100.0 if score_cp > 0 else -100.0)
427
 
428
  board_fen_only = board.fen().split(" ")[0]
429
  opening_name = openings_db.get(board_fen_only)
 
 
 
 
 
 
 
 
 
 
430
 
431
  return MoveResponse(
432
- bestmove=result.move.uci(),
433
  score=score_pawns,
434
  depth=depth,
435
  nodes=nodes,
@@ -445,20 +504,18 @@ async def get_analysis_move(request: MoveRequest):
445
  raise HTTPException(status_code=500, detail=str(e))
446
 
447
 
448
- import math
449
- import json
450
- import os
451
- from typing import Optional, List, Tuple
452
-
453
  openings_db = {}
454
  openings_path = os.path.join(os.path.dirname(__file__), "openings.json")
455
  if os.path.exists(openings_path):
456
  try:
457
  with open(openings_path, "r", encoding="utf-8") as f:
458
  openings_db = json.load(f)
459
- except Exception as e:
460
  pass
461
 
 
 
462
  def get_win_percentage_from_cp(cp: int) -> float:
463
  cp_ceiled = max(-1000, min(1000, cp))
464
  MULTIPLIER = -0.00368208
@@ -482,7 +539,10 @@ def is_losing_or_alt_winning(pos_win_pct: float, alt_win_pct: float, is_white_mo
482
 
483
  def get_has_changed_outcome(last_win_pct: float, pos_win_pct: float, is_white_move: bool) -> bool:
484
  diff = (pos_win_pct - last_win_pct) * (1 if is_white_move else -1)
485
- 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))
 
 
 
486
 
487
  def get_is_only_good_move(pos_win_pct: float, alt_win_pct: float, is_white_move: bool) -> bool:
488
  diff = (pos_win_pct - alt_win_pct) * (1 if is_white_move else -1)
@@ -492,10 +552,15 @@ def is_simple_recapture(fen_two_moves_ago: str, previous_move: chess.Move, playe
492
  if previous_move.to_square != played_move.to_square:
493
  return False
494
  b = chess.Board(fen_two_moves_ago)
495
- return b.piece_at(previous_move.to_square) is not None
 
 
496
 
497
  def get_material_difference(board: chess.Board) -> int:
498
- values = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3, chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 0}
 
 
 
499
  w = sum(values.get(p.piece_type, 0) for p in board.piece_map().values() if p.color == chess.WHITE)
500
  b = sum(values.get(p.piece_type, 0) for p in board.piece_map().values() if p.color == chess.BLACK)
501
  return w - b
@@ -505,22 +570,21 @@ def get_is_piece_sacrifice(board: chess.Board, played_move: chess.Move, best_pv:
505
  return False
506
  start_diff = get_material_difference(board)
507
  white_to_play = board.turn == chess.WHITE
508
-
509
  sim_board = board.copy()
510
  moves = [played_move] + best_pv
511
  if len(moves) % 2 == 1:
512
  moves = moves[:-1]
513
-
514
  captured_w = []
515
  captured_b = []
516
  non_capturing = 1
517
-
518
  for m in moves:
519
  if m in sim_board.legal_moves:
520
  captured_piece = sim_board.piece_at(m.to_square)
521
  if sim_board.is_en_passant(m):
522
  captured_piece = chess.Piece(chess.PAWN, not sim_board.turn)
523
-
524
  if captured_piece:
525
  if sim_board.turn == chess.WHITE:
526
  captured_b.append(captured_piece.piece_type)
@@ -534,19 +598,20 @@ def get_is_piece_sacrifice(board: chess.Board, played_move: chess.Move, best_pv:
534
  sim_board.push(m)
535
  else:
536
  break
537
-
538
  for p in captured_w[:]:
539
  if p in captured_b:
540
  captured_w.remove(p)
541
  captured_b.remove(p)
542
-
543
  if abs(len(captured_w) - len(captured_b)) <= 1 and all(p == chess.PAWN for p in captured_w + captured_b):
 
544
  return False
545
-
546
  end_diff = get_material_difference(sim_board)
 
547
  mat_diff = end_diff - start_diff
548
  player_rel = mat_diff if white_to_play else -mat_diff
549
-
550
  return player_rel < 0
551
 
552
  def get_move_classification(
@@ -571,10 +636,12 @@ def get_move_classification(
571
  if alt_win_pct is not None and diff >= -2.0:
572
  is_recapture = False
573
  if fen_two_moves_ago and uci_next_two_moves:
574
- is_recapture = is_simple_recapture(fen_two_moves_ago, uci_next_two_moves[0], uci_next_two_moves[1])
575
-
 
576
  if not is_recapture and not is_losing_or_alt_winning(pos_win_pct, alt_win_pct, is_white_move):
577
- 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):
 
578
  return "Great"
579
 
580
  if best_move_before and played_move == best_move_before:
@@ -582,21 +649,22 @@ def get_move_classification(
582
 
583
  if diff < -20.0: return "Blunder"
584
  if diff < -10.0: return "Mistake"
585
- if diff < -5.0: return "Inaccuracy"
586
- if diff < -2.0: return "Good"
587
  return "Excellent"
588
 
 
 
589
  @app.post("/analyze-game", response_model=AnalyzeResponse)
590
  async def analyze_game(request: AnalyzeRequest):
591
- engine = None
592
  try:
593
  engine = await get_stockfish_engine()
594
  board = chess.Board(request.start_fen) if request.start_fen else chess.Board()
595
  limit = chess.engine.Limit(time=request.time_per_move)
596
-
597
  analysis_results = []
598
-
599
  ply_timeout = _analyze_ply_timeout(request.time_per_move)
 
600
  async with _ENGINE_IO_LOCK:
601
  infos_before = await _engine_call(
602
  engine,
@@ -604,17 +672,20 @@ async def analyze_game(request: AnalyzeRequest):
604
  ply_timeout,
605
  )
606
  infos_before = infos_before if isinstance(infos_before, list) else [infos_before]
607
-
608
  counts = {
609
- "Book": 0, "Brilliant": 0, "Great": 0, "Best": 0,
610
- "Excellent": 0, "Good": 0, "Inaccuracy": 0,
611
  "Mistake": 0, "Blunder": 0
612
  }
613
 
614
  player_is_white = (request.player_color.lower() == "white")
615
-
616
- fen_history = [board.fen()]
617
- move_history = []
 
 
 
618
  total_cpl = 0.0
619
  player_moves_count = 0
620
  current_score, _ = get_normalized_score(infos_before[0])
@@ -622,34 +693,39 @@ async def analyze_game(request: AnalyzeRequest):
622
  for i, san_move in enumerate(request.moves):
623
  is_white_turn = board.turn == chess.WHITE
624
  is_player_turn = is_white_turn if player_is_white else not is_white_turn
625
-
626
- score_before = current_score
627
-
628
  try:
629
  move = board.parse_san(san_move)
630
  except Exception:
631
- break # Invalid move
632
 
633
  info_dict = infos_before[0]
634
  pv_list = info_dict.get("pv", [])
635
  best_move_before = pv_list[0] if pv_list else None
636
-
637
  score_before, _ = get_normalized_score(info_dict)
638
  win_pct_before = get_win_percentage(info_dict)
 
639
  alt_win_pct_before: Optional[float] = None
640
  if len(infos_before) > 1:
641
- # Find the first alternative move that is not the played move
642
  for line in infos_before:
643
  if line.get("pv") and line.get("pv")[0] != move:
644
  alt_win_pct_before = get_win_percentage(line)
645
  break
646
 
 
647
  board_before_move = board.copy()
648
  board.push(move)
649
-
650
- move_history.append(move)
651
- fen_history.append(board.fen())
652
-
 
 
 
 
 
 
653
  async with _ENGINE_IO_LOCK:
654
  infos_after_raw = await _engine_call(
655
  engine,
@@ -657,24 +733,23 @@ async def analyze_game(request: AnalyzeRequest):
657
  ply_timeout,
658
  )
659
  infos_after: List[dict] = infos_after_raw if isinstance(infos_after_raw, list) else [infos_after_raw]
660
-
661
  info_after_dict: dict = infos_after[0]
662
-
663
  win_pct_after = get_win_percentage(info_after_dict)
664
  score_after, _ = get_normalized_score(info_after_dict)
665
  current_score = score_after
666
-
667
  best_pv_after = info_after_dict.get("pv", [])
668
-
669
- fen_two_moves_ago = None
670
- uci_next_two_moves = None
671
- if len(move_history) >= 2:
672
- fen_two_moves_ago = fen_history[-3]
673
- uci_next_two_moves = (move_history[-2], move_history[-1])
674
 
 
 
 
 
675
  cls = "Book"
676
  opening_name = None
677
  board_fen_only = board.fen().split(" ")[0]
 
678
  if board_fen_only in openings_db:
679
  cls = "Book"
680
  opening_name = openings_db[board_fen_only]
@@ -691,18 +766,20 @@ async def analyze_game(request: AnalyzeRequest):
691
  board_before_move=board_before_move,
692
  best_pv_after=best_pv_after
693
  )
694
-
 
 
 
695
  move_gain = score_after - score_before if is_white_turn else score_before - score_after
696
- cpl = max(0, -move_gain)
697
- cpl = min(cpl, 1000.0)
698
-
699
  if is_player_turn:
700
  total_cpl += cpl
701
  player_moves_count += 1
702
  counts[cls] = counts.get(cls, 0) + 1
703
-
704
  analysis_results.append(MoveAnalysis(
705
- move_num=i+1,
706
  san=san_move,
707
  classification=cls,
708
  cpl=float(cpl),
@@ -711,28 +788,33 @@ async def analyze_game(request: AnalyzeRequest):
711
  best_move=best_move_before.uci() if best_move_before else "",
712
  opening=opening_name
713
  ))
714
-
 
715
  infos_before = infos_after
 
 
 
 
 
 
 
716
 
717
- # Win probability matching accuracy formula
718
- # Accuracy = 100 * exp(-0.02 * avg_cpl) smoothed
719
  avg_cpl = total_cpl / max(1, player_moves_count)
720
-
721
- # Simple heuristic mapping for Accuracy & Elo
722
- # 0 avg loss -> 100%
723
- # ~100 avg loss -> ~60%
724
  accuracy = max(10.0, min(100.0, 100.0 * math.exp(-0.005 * avg_cpl)))
725
-
726
- # Exponential Elo Decay calibrated to 3600 max engine strength
727
  estimated_elo = int(max(400, min(3600, round(3600 * math.exp(-0.015 * avg_cpl)))))
728
 
 
 
 
 
 
729
  return AnalyzeResponse(
730
  accuracy=round(accuracy, 1),
731
  estimated_elo=estimated_elo,
732
  moves=analysis_results,
733
  counts=counts
734
  )
735
-
736
  except HTTPException:
737
  raise
738
  except Exception as e:
@@ -742,5 +824,4 @@ async def analyze_game(request: AnalyzeRequest):
742
 
743
  if __name__ == "__main__":
744
  import uvicorn
745
- # Hugging Face Spaces port is 7860
746
  uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Request
2
  from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import JSONResponse
4
  from pydantic import BaseModel
5
+ from typing import Optional, List, Dict, Tuple
6
  from contextlib import asynccontextmanager
7
  import os
8
  import math
 
10
  import chess.engine
11
  import asyncio
12
  import json
13
+ import gc
14
 
15
+ # ─── Multiplayer / Challenge Manager ──────────────────────────────────────────
16
  class ConnectionManager:
17
  def __init__(self):
 
18
  self.active_connections: Dict[str, List[WebSocket]] = {}
19
 
20
  async def connect(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
+ # FIX: Clean up empty rooms so dict doesn't grow forever
31
  if not self.active_connections[match_id]:
32
  del self.active_connections[match_id]
33
 
34
  async def broadcast(self, message: str, match_id: str, exclude: WebSocket = None):
35
+ if match_id not in self.active_connections:
36
+ return
37
+ dead = []
38
+ for connection in self.active_connections[match_id]:
39
+ if connection == exclude:
40
+ continue
41
+ try:
42
+ await connection.send_text(message)
43
+ except Exception:
44
+ # FIX: Track dead sockets instead of silently ignoring them
45
+ dead.append(connection)
46
+ # FIX: Remove dead sockets after iteration to free memory
47
+ for d in dead:
48
+ self.active_connections[match_id].remove(d)
49
+ # FIX: Clean up empty room after removing dead sockets
50
+ if match_id in self.active_connections and not self.active_connections[match_id]:
51
+ del self.active_connections[match_id]
52
 
53
  manager = ConnectionManager()
54
 
 
60
  NNUE_PATH = os.environ.get("NNUE_PATH", "/app/engine_bin/output.nnue")
61
  NNUE_SMALL_PATH = os.environ.get("NNUE_SMALL_PATH", "/app/engine_bin/small_output.nnue")
62
 
63
+
64
  class MoveRequest(BaseModel):
65
  fen: str
66
+ time: float = 1.0
67
  depth: Optional[int] = None
68
 
69
  class MoveResponse(BaseModel):
 
98
  moves: List[MoveAnalysis]
99
  counts: Dict[str, int]
100
 
101
+
102
+ # Global engine instance
103
  _GLOBAL_DEEPCASTLE_ENGINE = None
104
  _ENGINE_LOCK = asyncio.Lock()
 
 
105
  _ENGINE_IO_LOCK = asyncio.Lock()
106
 
107
 
108
  def _engine_hash_mb() -> int:
 
109
  try:
110
+ v = int(os.environ.get("ENGINE_HASH_MB", "128"))
111
  except ValueError:
112
+ v = 128
113
  return max(8, min(512, v))
114
 
115
 
116
  async def _get_or_start_engine(engine_path: str, *, role: str, options: Optional[dict] = None):
117
  global _GLOBAL_DEEPCASTLE_ENGINE
118
 
 
119
  current_engine = _GLOBAL_DEEPCASTLE_ENGINE
120
  if current_engine is not None:
121
  try:
 
163
  print(f"[WARNING] EvalFileSmall not found at {NNUE_SMALL_PATH}")
164
 
165
  _GLOBAL_DEEPCASTLE_ENGINE = engine
 
166
  return engine
167
  except Exception as e:
168
  raise HTTPException(status_code=500, detail=f"{role} crash: {str(e)}")
 
176
  )
177
 
178
  async def get_stockfish_engine():
 
179
  return await get_deepcastle_engine()
180
 
181
 
182
+ async def _clear_engine_hash(engine) -> None:
183
+ """Send ucinewgame to clear the engine hash table and reset internal state."""
184
+ try:
185
+ await engine.send_command("ucinewgame")
186
+ await asyncio.wait_for(engine.ping(), timeout=5.0)
187
+ except Exception as e:
188
+ print(f"[WARNING] Failed to clear engine hash: {e}")
189
+
190
+
191
  async def shutdown_engine_async() -> None:
 
192
  global _GLOBAL_DEEPCASTLE_ENGINE
193
  async with _ENGINE_IO_LOCK:
194
  async with _ENGINE_LOCK:
 
202
 
203
 
204
  async def _detach_and_quit_engine(engine) -> None:
 
205
  global _GLOBAL_DEEPCASTLE_ENGINE
206
  async with _ENGINE_LOCK:
207
  if _GLOBAL_DEEPCASTLE_ENGINE is engine:
 
213
 
214
 
215
  def _search_timeout_sec(request_time: float, depth: Optional[int] = None) -> float:
 
216
  try:
217
  cap = float(os.environ.get("ENGINE_SEARCH_TIMEOUT_SEC", "120"))
218
  except ValueError:
 
224
 
225
 
226
  def _analyze_ply_timeout(time_per_move: float) -> float:
 
227
  try:
228
  cap = float(os.environ.get("ENGINE_SEARCH_TIMEOUT_SEC", "120"))
229
  except ValueError:
 
250
 
251
  app = FastAPI(title="Deepcastle Engine API", lifespan=lifespan)
252
 
253
+ # FIX: Global timeout middleware β€” kills hung requests so they don't queue in memory
254
+ @app.middleware("http")
255
+ async def timeout_middleware(request: Request, call_next):
256
+ try:
257
+ return await asyncio.wait_for(call_next(request), timeout=180.0)
258
+ except asyncio.TimeoutError:
259
+ return JSONResponse({"detail": "Request timed out"}, status_code=504)
260
+
261
  app.add_middleware(
262
  CORSMiddleware,
263
  allow_origins=["*"],
 
266
  )
267
 
268
 
269
+ # ─── WebSocket ─────────────────────────────────────────────────────────────────
270
  @app.websocket("/ws/{match_id}")
271
  async def websocket_endpoint(websocket: WebSocket, match_id: str):
272
  await manager.connect(websocket, match_id)
 
273
  await manager.broadcast(json.dumps({"type": "join"}), match_id, exclude=websocket)
274
  try:
275
  while True:
 
277
  await manager.broadcast(data, match_id, exclude=websocket)
278
  except WebSocketDisconnect:
279
  manager.disconnect(websocket, match_id)
280
+ # FIX: Broadcast disconnect then nudge GC to free room state
281
  await manager.broadcast(json.dumps({"type": "opponent_disconnected"}), match_id)
282
+ gc.collect()
283
  except Exception:
284
  manager.disconnect(websocket, match_id)
285
  await manager.broadcast(json.dumps({"type": "opponent_disconnected"}), match_id)
286
+ gc.collect()
287
 
288
 
289
+ # ─── Health ────────────────────────────────────────────────────────────────────
290
  @app.get("/")
291
  def home():
292
  return {"status": "online", "engine": "Deepcastle Hybrid Neural", "platform": "Hugging Face Spaces"}
293
 
294
 
295
+ # FIX: Accept HEAD requests from UptimeRobot (was returning 405)
296
  @app.api_route("/health", methods=["GET", "HEAD"])
297
  def health():
298
  if not os.path.exists(DEEPCASTLE_ENGINE_PATH):
299
  return {"status": "error", "message": "Missing engine binary: deepcastle"}
300
+ # FIX: Nudge GC on every health ping
301
+ gc.collect()
302
  return {"status": "ok", "engine": "deepcastle"}
303
 
304
 
305
  @app.get("/health/ready")
306
  async def health_ready():
 
307
  if not os.path.exists(DEEPCASTLE_ENGINE_PATH):
308
  raise HTTPException(status_code=503, detail="Missing engine binary")
309
  try:
 
317
  raise HTTPException(status_code=503, detail=str(e))
318
 
319
 
320
+ # FIX: New endpoint β€” call from frontend when a game starts or ends
321
+ @app.post("/new-game")
322
+ async def new_game():
323
+ """
324
+ Clear engine hash table between games.
325
+ Call this from the frontend at these moments:
326
+ - When user starts a new game vs bot
327
+ - When game ends (checkmate / resign / draw)
328
+ - When multiplayer match starts
329
+ - When multiplayer match ends
330
+ """
331
+ try:
332
+ engine = await get_deepcastle_engine()
333
+ async with _ENGINE_IO_LOCK:
334
+ await _clear_engine_hash(engine)
335
+ gc.collect()
336
+ return {"status": "ok", "message": "Engine hash cleared"}
337
+ except HTTPException:
338
+ raise
339
+ except Exception as e:
340
+ raise HTTPException(status_code=500, detail=str(e))
341
+
342
+
343
+ # ─── Helpers ───────────────────────────────────────────────────────────────────
344
+ def get_normalized_score(info) -> Tuple[float, Optional[int]]:
345
  if "score" not in info:
346
  return 0.0, None
347
  raw = info["score"].white()
 
351
  return float(raw.score() or 0.0), None
352
 
353
 
354
+ def normalize_search_stats(info: dict) -> Tuple[int, int, int]:
 
 
 
 
 
355
  depth = int(info.get("depth") or 0)
356
  nodes = int(info.get("nodes") or 0)
357
  t = info.get("time")
 
363
  return depth, nodes, nps
364
 
365
 
366
+ # ─── Bot Move (/move) ──────────────────────────────────────────────────────────
367
  @app.post("/move", response_model=MoveResponse)
368
  async def get_move(request: MoveRequest):
369
  try:
370
  engine = await get_deepcastle_engine()
371
  board = chess.Board(request.fen)
372
  limit = chess.engine.Limit(time=request.time, depth=request.depth)
 
 
373
  tsec = _search_timeout_sec(request.time, request.depth)
374
+
375
  async with _ENGINE_IO_LOCK:
376
  result = await _engine_call(
377
  engine,
 
385
  engine.analyse(board, limit, info=chess.engine.INFO_ALL),
386
  tsec,
387
  )
388
+
 
389
  score_cp, mate_in = get_normalized_score(info)
 
390
  depth, nodes, nps = normalize_search_stats(info)
391
 
392
+ # FIX: Use a local pv_board and delete it after use
393
  pv_board = board.copy()
394
  pv_parts = []
395
  for m in info.get("pv", [])[:5]:
 
402
  else:
403
  break
404
  pv = " ".join(pv_parts)
405
+ del pv_board # FIX: Explicitly free board copy
406
 
 
407
  score_pawns = score_cp / 100.0 if abs(score_cp) < 9900 else (100.0 if score_cp > 0 else -100.0)
408
 
 
409
  board_fen_only = board.fen().split(" ")[0]
410
  opening_name = openings_db.get(board_fen_only)
411
+ best_move = result.move.uci()
412
+
413
+ # FIX: Explicitly release engine result and info objects
414
+ del result
415
+ del info
416
 
417
  return MoveResponse(
418
+ bestmove=best_move,
419
  score=score_pawns,
420
  depth=depth,
421
  nodes=nodes,
 
430
  print(f"Error: {e}")
431
  raise HTTPException(status_code=500, detail=str(e))
432
 
433
+
434
+ # ─── Hint Move (/analysis-move) ───────────────────────────────────────────────
435
  @app.post("/analysis-move", response_model=MoveResponse)
436
  async def get_analysis_move(request: MoveRequest):
437
  try:
438
  engine = await get_stockfish_engine()
439
  board = chess.Board(request.fen)
440
  limit = chess.engine.Limit(time=request.time, depth=request.depth)
 
441
  tsec = _search_timeout_sec(request.time, request.depth)
442
+
443
  async with _ENGINE_IO_LOCK:
444
  result = await _engine_call(
445
  engine,
 
455
  )
456
 
457
  score_cp, mate_in = get_normalized_score(info)
 
458
  depth, nodes, nps = normalize_search_stats(info)
459
 
460
+ # FIX: Use a local pv_board and delete it after use
461
  pv_board = board.copy()
462
  pv_parts = []
463
  for m in info.get("pv", [])[:5]:
 
470
  else:
471
  break
472
  pv = " ".join(pv_parts)
473
+ del pv_board # FIX: Explicitly free board copy
474
 
475
  score_pawns = score_cp / 100.0 if abs(score_cp) < 9900 else (100.0 if score_cp > 0 else -100.0)
476
 
477
  board_fen_only = board.fen().split(" ")[0]
478
  opening_name = openings_db.get(board_fen_only)
479
+ best_move = result.move.uci()
480
+
481
+ # FIX: Explicitly release engine result and info objects
482
+ del result
483
+ del info
484
+
485
+ # FIX: Clear hash after hint β€” hint is a one-shot search, no continuity needed
486
+ async with _ENGINE_IO_LOCK:
487
+ await _clear_engine_hash(engine)
488
+ gc.collect()
489
 
490
  return MoveResponse(
491
+ bestmove=best_move,
492
  score=score_pawns,
493
  depth=depth,
494
  nodes=nodes,
 
504
  raise HTTPException(status_code=500, detail=str(e))
505
 
506
 
507
+ # ─── Openings DB ───────────────────────────────────────────────────────────────
 
 
 
 
508
  openings_db = {}
509
  openings_path = os.path.join(os.path.dirname(__file__), "openings.json")
510
  if os.path.exists(openings_path):
511
  try:
512
  with open(openings_path, "r", encoding="utf-8") as f:
513
  openings_db = json.load(f)
514
+ except Exception:
515
  pass
516
 
517
+
518
+ # ─── Move Classification Helpers ───────────────────────────────────────────────
519
  def get_win_percentage_from_cp(cp: int) -> float:
520
  cp_ceiled = max(-1000, min(1000, cp))
521
  MULTIPLIER = -0.00368208
 
539
 
540
  def get_has_changed_outcome(last_win_pct: float, pos_win_pct: float, is_white_move: bool) -> bool:
541
  diff = (pos_win_pct - last_win_pct) * (1 if is_white_move else -1)
542
+ return diff > 10.0 and (
543
+ (last_win_pct < 50.0 and pos_win_pct > 50.0) or
544
+ (last_win_pct > 50.0 and pos_win_pct < 50.0)
545
+ )
546
 
547
  def get_is_only_good_move(pos_win_pct: float, alt_win_pct: float, is_white_move: bool) -> bool:
548
  diff = (pos_win_pct - alt_win_pct) * (1 if is_white_move else -1)
 
552
  if previous_move.to_square != played_move.to_square:
553
  return False
554
  b = chess.Board(fen_two_moves_ago)
555
+ result = b.piece_at(previous_move.to_square) is not None
556
+ del b # FIX: Free temp board
557
+ return result
558
 
559
  def get_material_difference(board: chess.Board) -> int:
560
+ values = {
561
+ chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3,
562
+ chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 0
563
+ }
564
  w = sum(values.get(p.piece_type, 0) for p in board.piece_map().values() if p.color == chess.WHITE)
565
  b = sum(values.get(p.piece_type, 0) for p in board.piece_map().values() if p.color == chess.BLACK)
566
  return w - b
 
570
  return False
571
  start_diff = get_material_difference(board)
572
  white_to_play = board.turn == chess.WHITE
573
+
574
  sim_board = board.copy()
575
  moves = [played_move] + best_pv
576
  if len(moves) % 2 == 1:
577
  moves = moves[:-1]
578
+
579
  captured_w = []
580
  captured_b = []
581
  non_capturing = 1
582
+
583
  for m in moves:
584
  if m in sim_board.legal_moves:
585
  captured_piece = sim_board.piece_at(m.to_square)
586
  if sim_board.is_en_passant(m):
587
  captured_piece = chess.Piece(chess.PAWN, not sim_board.turn)
 
588
  if captured_piece:
589
  if sim_board.turn == chess.WHITE:
590
  captured_b.append(captured_piece.piece_type)
 
598
  sim_board.push(m)
599
  else:
600
  break
601
+
602
  for p in captured_w[:]:
603
  if p in captured_b:
604
  captured_w.remove(p)
605
  captured_b.remove(p)
606
+
607
  if abs(len(captured_w) - len(captured_b)) <= 1 and all(p == chess.PAWN for p in captured_w + captured_b):
608
+ del sim_board
609
  return False
610
+
611
  end_diff = get_material_difference(sim_board)
612
+ del sim_board # FIX: Free temp board
613
  mat_diff = end_diff - start_diff
614
  player_rel = mat_diff if white_to_play else -mat_diff
 
615
  return player_rel < 0
616
 
617
  def get_move_classification(
 
636
  if alt_win_pct is not None and diff >= -2.0:
637
  is_recapture = False
638
  if fen_two_moves_ago and uci_next_two_moves:
639
+ is_recapture = is_simple_recapture(
640
+ fen_two_moves_ago, uci_next_two_moves[0], uci_next_two_moves[1]
641
+ )
642
  if not is_recapture and not is_losing_or_alt_winning(pos_win_pct, alt_win_pct, is_white_move):
643
+ if get_has_changed_outcome(last_win_pct, pos_win_pct, is_white_move) or \
644
+ get_is_only_good_move(pos_win_pct, alt_win_pct, is_white_move):
645
  return "Great"
646
 
647
  if best_move_before and played_move == best_move_before:
 
649
 
650
  if diff < -20.0: return "Blunder"
651
  if diff < -10.0: return "Mistake"
652
+ if diff < -5.0: return "Inaccuracy"
653
+ if diff < -2.0: return "Good"
654
  return "Excellent"
655
 
656
+
657
+ # ─── Game Analysis (/analyze-game) ────────────────────────────────────────────
658
  @app.post("/analyze-game", response_model=AnalyzeResponse)
659
  async def analyze_game(request: AnalyzeRequest):
 
660
  try:
661
  engine = await get_stockfish_engine()
662
  board = chess.Board(request.start_fen) if request.start_fen else chess.Board()
663
  limit = chess.engine.Limit(time=request.time_per_move)
664
+
665
  analysis_results = []
 
666
  ply_timeout = _analyze_ply_timeout(request.time_per_move)
667
+
668
  async with _ENGINE_IO_LOCK:
669
  infos_before = await _engine_call(
670
  engine,
 
672
  ply_timeout,
673
  )
674
  infos_before = infos_before if isinstance(infos_before, list) else [infos_before]
675
+
676
  counts = {
677
+ "Book": 0, "Brilliant": 0, "Great": 0, "Best": 0,
678
+ "Excellent": 0, "Good": 0, "Inaccuracy": 0,
679
  "Mistake": 0, "Blunder": 0
680
  }
681
 
682
  player_is_white = (request.player_color.lower() == "white")
683
+
684
+ # FIX: Sliding window instead of ever-growing lists
685
+ # We only ever need the last 3 FENs and last 2 moves for classification
686
+ fen_window: List[str] = [board.fen()]
687
+ move_window: List[chess.Move] = []
688
+
689
  total_cpl = 0.0
690
  player_moves_count = 0
691
  current_score, _ = get_normalized_score(infos_before[0])
 
693
  for i, san_move in enumerate(request.moves):
694
  is_white_turn = board.turn == chess.WHITE
695
  is_player_turn = is_white_turn if player_is_white else not is_white_turn
696
+
 
 
697
  try:
698
  move = board.parse_san(san_move)
699
  except Exception:
700
+ break
701
 
702
  info_dict = infos_before[0]
703
  pv_list = info_dict.get("pv", [])
704
  best_move_before = pv_list[0] if pv_list else None
705
+
706
  score_before, _ = get_normalized_score(info_dict)
707
  win_pct_before = get_win_percentage(info_dict)
708
+
709
  alt_win_pct_before: Optional[float] = None
710
  if len(infos_before) > 1:
 
711
  for line in infos_before:
712
  if line.get("pv") and line.get("pv")[0] != move:
713
  alt_win_pct_before = get_win_percentage(line)
714
  break
715
 
716
+ # FIX: Copy board before move, delete right after classification
717
  board_before_move = board.copy()
718
  board.push(move)
719
+
720
+ # FIX: Sliding window β€” discard oldest entry beyond what we need
721
+ move_window.append(move)
722
+ if len(move_window) > 2:
723
+ move_window.pop(0)
724
+
725
+ fen_window.append(board.fen())
726
+ if len(fen_window) > 3:
727
+ fen_window.pop(0)
728
+
729
  async with _ENGINE_IO_LOCK:
730
  infos_after_raw = await _engine_call(
731
  engine,
 
733
  ply_timeout,
734
  )
735
  infos_after: List[dict] = infos_after_raw if isinstance(infos_after_raw, list) else [infos_after_raw]
736
+
737
  info_after_dict: dict = infos_after[0]
738
+
739
  win_pct_after = get_win_percentage(info_after_dict)
740
  score_after, _ = get_normalized_score(info_after_dict)
741
  current_score = score_after
742
+
743
  best_pv_after = info_after_dict.get("pv", [])
 
 
 
 
 
 
744
 
745
+ fen_two_moves_ago = fen_window[0] if len(fen_window) == 3 else None
746
+ uci_next_two_moves = tuple(move_window[-2:]) if len(move_window) >= 2 else None
747
+
748
+ # Classify
749
  cls = "Book"
750
  opening_name = None
751
  board_fen_only = board.fen().split(" ")[0]
752
+
753
  if board_fen_only in openings_db:
754
  cls = "Book"
755
  opening_name = openings_db[board_fen_only]
 
766
  board_before_move=board_before_move,
767
  best_pv_after=best_pv_after
768
  )
769
+
770
+ # FIX: Free board copy immediately after classification is done
771
+ del board_before_move
772
+
773
  move_gain = score_after - score_before if is_white_turn else score_before - score_after
774
+ cpl = max(0.0, min(-move_gain, 1000.0))
775
+
 
776
  if is_player_turn:
777
  total_cpl += cpl
778
  player_moves_count += 1
779
  counts[cls] = counts.get(cls, 0) + 1
780
+
781
  analysis_results.append(MoveAnalysis(
782
+ move_num=i + 1,
783
  san=san_move,
784
  classification=cls,
785
  cpl=float(cpl),
 
788
  best_move=best_move_before.uci() if best_move_before else "",
789
  opening=opening_name
790
  ))
791
+
792
+ # FIX: Release large engine result objects after each ply
793
  infos_before = infos_after
794
+ infos_after = None
795
+ info_after_dict = None
796
+ infos_after_raw = None
797
+
798
+ # FIX: Free sliding windows after loop
799
+ del fen_window
800
+ del move_window
801
 
 
 
802
  avg_cpl = total_cpl / max(1, player_moves_count)
 
 
 
 
803
  accuracy = max(10.0, min(100.0, 100.0 * math.exp(-0.005 * avg_cpl)))
 
 
804
  estimated_elo = int(max(400, min(3600, round(3600 * math.exp(-0.015 * avg_cpl)))))
805
 
806
+ # FIX: Clear engine hash after full game analysis β€” analysis fills hash very fast
807
+ async with _ENGINE_IO_LOCK:
808
+ await _clear_engine_hash(engine)
809
+ gc.collect()
810
+
811
  return AnalyzeResponse(
812
  accuracy=round(accuracy, 1),
813
  estimated_elo=estimated_elo,
814
  moves=analysis_results,
815
  counts=counts
816
  )
817
+
818
  except HTTPException:
819
  raise
820
  except Exception as e:
 
824
 
825
  if __name__ == "__main__":
826
  import uvicorn
 
827
  uvicorn.run(app, host="0.0.0.0", port=7860)