Amogh1221 commited on
Commit
a385e89
Β·
verified Β·
1 Parent(s): 30d6d09

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +252 -323
main.py CHANGED
@@ -1,4 +1,3 @@
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,213 +9,73 @@ import chess.engine
10
  import asyncio
11
  import json
12
 
13
- # ─── Constants ────────────────────────────────────────────────────────────────
14
- ENGINE_PATH = os.environ.get("ENGINE_PATH", "/app/engine/deepcastle")
15
- NNUE_PATH = os.environ.get("NNUE_PATH", "/app/engine/output.nnue")
16
- POOL_SIZE = 4
17
-
18
- # ─── Engine Pool ───────────────────────────────────────────────────────────────
19
- class EnginePool:
20
- def __init__(self):
21
- self._queue: asyncio.Queue = asyncio.Queue()
22
- self._all_engines: list = []
23
-
24
- async def _spawn(self):
25
- transport, engine = await chess.engine.popen_uci(ENGINE_PATH)
26
-
27
- # Conservative, proven-safe values β€” 4Γ—128MB = 512MB total
28
- options = {"Threads": 1, "Hash": 128}
29
-
30
- # Only set EvalFile if the NNUE actually exists β€” missing file = exit code 1
31
- for candidate in [
32
- NNUE_PATH,
33
- "/app/engine/custom_big.nnue",
34
- "/app/engine/nn-9a0cc2a62c52.nnue",
35
- "/app/engine/nn-47fc8b7fff06.nnue",
36
- ]:
37
- if os.path.exists(candidate):
38
- options["EvalFile"] = candidate
39
- print(f"[Pool] Using NNUE: {candidate}")
40
- break
41
- else:
42
- print("[Pool] WARNING: No NNUE file found β€” using classical eval")
43
-
44
- try:
45
- await engine.configure(options)
46
- except Exception as e:
47
- print(f"[Pool] configure warning: {e}")
48
-
49
- # Verify the engine is actually alive before returning it
50
- try:
51
- test_board = chess.Board()
52
- await asyncio.wait_for(
53
- engine.analyse(test_board, chess.engine.Limit(time=0.05)),
54
- timeout=5.0
55
- )
56
- print("[Pool] Engine spawn verified OK")
57
- except Exception as e:
58
- print(f"[Pool] Engine failed verification: {e}")
59
- try:
60
- await engine.quit()
61
- except Exception:
62
- pass
63
- raise RuntimeError(f"Engine spawn failed verification: {e}")
64
-
65
- return engine
66
-
67
- async def start(self):
68
- for i in range(POOL_SIZE):
69
- try:
70
- engine = await self._spawn()
71
- self._all_engines.append(engine)
72
- await self._queue.put(engine)
73
- print(f"[Pool] Engine {i+1}/{POOL_SIZE} ready")
74
- except Exception as e:
75
- print(f"[Pool] CRITICAL: Engine {i+1} failed to start: {e}")
76
-
77
- ready = self._queue.qsize()
78
- if ready == 0:
79
- raise RuntimeError("No engines could start β€” check ENGINE_PATH and binary")
80
- print(f"[Pool] {ready}/{POOL_SIZE} engines ready")
81
-
82
- async def _replace_engine(self):
83
- try:
84
- fresh = await self._spawn()
85
- await self._queue.put(fresh)
86
- print("[Pool] Replacement engine added to pool")
87
- except Exception as e:
88
- print(f"[Pool] CRITICAL: could not replace engine: {e}")
89
-
90
- @asynccontextmanager
91
- async def acquire(self, timeout: float = 10.0):
92
- try:
93
- engine = await asyncio.wait_for(self._queue.get(), timeout=timeout)
94
- except asyncio.TimeoutError:
95
- raise HTTPException(status_code=503,
96
- detail="All engines busy β€” try again shortly")
97
- healthy = True
98
- try:
99
- yield engine
100
- except chess.engine.EngineTerminatedError:
101
- healthy = False
102
- raise HTTPException(status_code=500,
103
- detail="Engine crashed β€” please retry")
104
- except Exception:
105
- healthy = False
106
- raise
107
- finally:
108
- if healthy:
109
- await self._queue.put(engine)
110
- else:
111
- try:
112
- await engine.quit()
113
- except Exception:
114
- pass
115
- asyncio.create_task(self._replace_engine())
116
-
117
- async def shutdown(self):
118
- engines = []
119
- while not self._queue.empty():
120
- try:
121
- engines.append(self._queue.get_nowait())
122
- except asyncio.QueueEmpty:
123
- break
124
- for e in engines:
125
- try:
126
- await e.quit()
127
- except Exception:
128
- pass
129
- print("[Pool] all engines shut down")
130
-
131
-
132
- pool = EnginePool()
133
-
134
-
135
- # ─── Openings DB ────────────────────────────────────────────────────���─────────
136
- openings_db: dict = {}
137
-
138
- def load_openings():
139
- global openings_db
140
- path = os.path.join(os.path.dirname(__file__), "openings.json")
141
- if os.path.exists(path):
142
- try:
143
- with open(path, "r", encoding="utf-8") as f:
144
- openings_db = json.load(f)
145
- print(f"[Openings] Loaded {len(openings_db)} positions")
146
- except Exception as e:
147
- print(f"[Openings] Load error: {e}")
148
- else:
149
- print("[Openings] openings.json not found β€” opening detection disabled")
150
-
151
-
152
- # ─── FastAPI Lifespan ─────────────────────────────────────────────────────────
153
- @asynccontextmanager
154
- async def lifespan(app: FastAPI):
155
- load_openings()
156
- await pool.start()
157
- yield
158
- await pool.shutdown()
159
-
160
- app = FastAPI(title="Deepcastle Engine API", lifespan=lifespan)
161
-
162
-
163
- # ─── CORS ─────────────────────────────────────────────────────────────────────
164
- app.add_middleware(
165
- CORSMiddleware,
166
- allow_origins=["*"],
167
- allow_methods=["*"],
168
- allow_headers=["*"],
169
- )
170
 
171
-
172
- # ─── WebSocket / Multiplayer ──────────────────────────────────────────────────
173
  class ConnectionManager:
174
  def __init__(self):
 
175
  self.active_connections: Dict[str, List[WebSocket]] = {}
176
 
177
  async def connect(self, websocket: WebSocket, match_id: str):
178
  await websocket.accept()
179
- self.active_connections.setdefault(match_id, []).append(websocket)
 
 
180
 
181
  def disconnect(self, websocket: WebSocket, match_id: str):
182
  if match_id in self.active_connections:
183
- try:
184
  self.active_connections[match_id].remove(websocket)
185
- except ValueError:
186
- pass
187
  if not self.active_connections[match_id]:
188
  del self.active_connections[match_id]
189
 
190
  async def broadcast(self, message: str, match_id: str, exclude: WebSocket = None):
191
- for conn in self.active_connections.get(match_id, []):
192
- if conn != exclude:
193
- try:
194
- await conn.send_text(message)
195
- except Exception:
196
- pass
 
197
 
198
  manager = ConnectionManager()
199
 
200
  @app.websocket("/ws/{match_id}")
201
  async def websocket_endpoint(websocket: WebSocket, match_id: str):
202
  await manager.connect(websocket, match_id)
 
 
203
  await manager.broadcast(json.dumps({"type": "join"}), match_id, exclude=websocket)
204
  try:
205
  while True:
206
  data = await websocket.receive_text()
 
207
  await manager.broadcast(data, match_id, exclude=websocket)
208
  except WebSocketDisconnect:
209
  manager.disconnect(websocket, match_id)
 
210
  await manager.broadcast(json.dumps({"type": "opponent_disconnected"}), match_id)
211
  except Exception:
212
  manager.disconnect(websocket, match_id)
213
  await manager.broadcast(json.dumps({"type": "opponent_disconnected"}), match_id)
214
 
215
 
216
- # ─── Pydantic Models ──────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
217
  class MoveRequest(BaseModel):
218
  fen: str
219
- time: float = 1.0
220
  depth: Optional[int] = None
221
 
222
  class MoveResponse(BaseModel):
@@ -251,9 +110,30 @@ class AnalyzeResponse(BaseModel):
251
  moves: List[MoveAnalysis]
252
  counts: Dict[str, int]
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
- # ─── Score Utilities ──────────────────────────────────────────────────────────
256
  def get_normalized_score(info) -> tuple[float, Optional[int]]:
 
257
  if "score" not in info:
258
  return 0.0, None
259
  raw = info["score"].white()
@@ -262,6 +142,80 @@ def get_normalized_score(info) -> tuple[float, Optional[int]]:
262
  return (10000.0 if m > 0 else -10000.0), m
263
  return raw.score() or 0.0, None
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  def get_win_percentage_from_cp(cp: int) -> float:
266
  cp_ceiled = max(-1000, min(1000, cp))
267
  MULTIPLIER = -0.00368208
@@ -274,23 +228,22 @@ def get_win_percentage(info: dict) -> float:
274
  return 50.0
275
  white_score = score.white()
276
  if white_score.is_mate():
277
- return 100.0 if white_score.mate() > 0 else 0.0
 
278
  return get_win_percentage_from_cp(white_score.score())
279
 
280
  def is_losing_or_alt_winning(pos_win_pct: float, alt_win_pct: float, is_white_move: bool) -> bool:
281
- is_losing = pos_win_pct < 50.0 if is_white_move else pos_win_pct > 50.0
282
  is_alt_winning = alt_win_pct > 97.0 if is_white_move else alt_win_pct < 3.0
283
  return is_losing or is_alt_winning
284
 
285
  def get_has_changed_outcome(last_win_pct: float, pos_win_pct: float, is_white_move: bool) -> bool:
286
  diff = (pos_win_pct - last_win_pct) * (1 if is_white_move else -1)
287
- return diff > 10.0 and (
288
- (last_win_pct < 50.0 and pos_win_pct > 50.0) or
289
- (last_win_pct > 50.0 and pos_win_pct < 50.0)
290
- )
291
 
292
  def get_is_only_good_move(pos_win_pct: float, alt_win_pct: float, is_white_move: bool) -> bool:
293
- return (pos_win_pct - alt_win_pct) * (1 if is_white_move else -1) > 10.0
 
294
 
295
  def is_simple_recapture(fen_two_moves_ago: str, previous_move: chess.Move, played_move: chess.Move) -> bool:
296
  if previous_move.to_square != played_move.to_square:
@@ -299,8 +252,7 @@ def is_simple_recapture(fen_two_moves_ago: str, previous_move: chess.Move, playe
299
  return b.piece_at(previous_move.to_square) is not None
300
 
301
  def get_material_difference(board: chess.Board) -> int:
302
- values = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3,
303
- chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 0}
304
  w = sum(values.get(p.piece_type, 0) for p in board.piece_map().values() if p.color == chess.WHITE)
305
  b = sum(values.get(p.piece_type, 0) for p in board.piece_map().values() if p.color == chess.BLACK)
306
  return w - b
@@ -308,20 +260,29 @@ def get_material_difference(board: chess.Board) -> int:
308
  def get_is_piece_sacrifice(board: chess.Board, played_move: chess.Move, best_pv: list) -> bool:
309
  if not best_pv:
310
  return False
 
311
  white_to_play = board.turn == chess.WHITE
 
312
  sim_board = board.copy()
313
  moves = [played_move] + best_pv
314
  if len(moves) % 2 == 1:
315
  moves = moves[:-1]
316
- captured_w, captured_b = [], []
 
 
317
  non_capturing = 1
 
318
  for m in moves:
319
  if m in sim_board.legal_moves:
320
  captured_piece = sim_board.piece_at(m.to_square)
321
  if sim_board.is_en_passant(m):
322
  captured_piece = chess.Piece(chess.PAWN, not sim_board.turn)
 
323
  if captured_piece:
324
- (captured_b if sim_board.turn == chess.WHITE else captured_w).append(captured_piece.piece_type)
 
 
 
325
  non_capturing = 1
326
  else:
327
  non_capturing -= 1
@@ -330,20 +291,32 @@ def get_is_piece_sacrifice(board: chess.Board, played_move: chess.Move, best_pv:
330
  sim_board.push(m)
331
  else:
332
  break
 
333
  for p in captured_w[:]:
334
  if p in captured_b:
335
  captured_w.remove(p)
336
  captured_b.remove(p)
 
337
  if abs(len(captured_w) - len(captured_b)) <= 1 and all(p == chess.PAWN for p in captured_w + captured_b):
338
  return False
339
- mat_diff = get_material_difference(sim_board) - get_material_difference(board)
340
- return (mat_diff if white_to_play else -mat_diff) < 0
 
 
 
 
341
 
342
  def get_move_classification(
343
- last_win_pct, pos_win_pct, is_white_move,
344
- played_move, best_move_before, alt_win_pct,
345
- fen_two_moves_ago, uci_next_two_moves,
346
- board_before_move, best_pv_after
 
 
 
 
 
 
347
  ) -> str:
348
  diff = (pos_win_pct - last_win_pct) * (1 if is_white_move else -1)
349
 
@@ -355,10 +328,10 @@ def get_move_classification(
355
  if alt_win_pct is not None and diff >= -2.0:
356
  is_recapture = False
357
  if fen_two_moves_ago and uci_next_two_moves:
358
- is_recapture = is_simple_recapture(fen_two_moves_ago, *uci_next_two_moves)
 
359
  if not is_recapture and not is_losing_or_alt_winning(pos_win_pct, alt_win_pct, is_white_move):
360
- if get_has_changed_outcome(last_win_pct, pos_win_pct, is_white_move) or \
361
- get_is_only_good_move(pos_win_pct, alt_win_pct, is_white_move):
362
  return "Great"
363
 
364
  if best_move_before and played_move == best_move_before:
@@ -366,147 +339,90 @@ def get_move_classification(
366
 
367
  if diff < -20.0: return "Blunder"
368
  if diff < -10.0: return "Mistake"
369
- if diff < -5.0: return "Inaccuracy"
370
- if diff < -2.0: return "Good"
371
  return "Excellent"
372
 
373
-
374
- # ─── Routes ───────────────────────────────────────────────────────────────────
375
- @app.get("/")
376
- def home():
377
- return {
378
- "status": "online",
379
- "engine": "Deepcastle Hybrid Neural",
380
- "platform": "Hugging Face Spaces",
381
- "pool_size": POOL_SIZE,
382
- }
383
-
384
- @app.get("/health")
385
- def health():
386
- if not os.path.exists(ENGINE_PATH):
387
- return {"status": "error", "message": "Engine binary not found"}
388
- return {
389
- "status": "ok",
390
- "engine": "Deepcastle",
391
- "pool_size": POOL_SIZE,
392
- "idle_engines": pool._queue.qsize(),
393
- }
394
-
395
- @app.get("/pool-status")
396
- def pool_status():
397
- return {"idle_engines": pool._queue.qsize(), "total_engines": POOL_SIZE}
398
-
399
-
400
- @app.post("/move", response_model=MoveResponse)
401
- async def get_move(request: MoveRequest):
402
- async with pool.acquire(timeout=10.0) as engine:
403
- board = chess.Board(request.fen)
404
- limit = chess.engine.Limit(time=request.time, depth=request.depth)
405
-
406
- result = await engine.play(board, limit)
407
- info = await engine.analyse(board, limit)
408
-
409
- score_cp, mate_in = get_normalized_score(info)
410
- depth = info.get("depth", 0)
411
- nodes = info.get("nodes", 0)
412
- nps = info.get("nps", 0)
413
-
414
- pv_board, pv_parts = board.copy(), []
415
- for m in info.get("pv", [])[:5]:
416
- if m in pv_board.legal_moves:
417
- try:
418
- pv_parts.append(pv_board.san(m))
419
- pv_board.push(m)
420
- except Exception:
421
- break
422
- else:
423
- break
424
-
425
- score_pawns = score_cp / 100.0 if abs(score_cp) < 9900 else (100.0 if score_cp > 0 else -100.0)
426
- board_fen_only = board.fen().split(" ")[0]
427
-
428
- return MoveResponse(
429
- bestmove=result.move.uci(),
430
- score=score_pawns,
431
- depth=depth,
432
- nodes=nodes,
433
- nps=nps,
434
- pv=" ".join(pv_parts),
435
- mate_in=mate_in,
436
- opening=openings_db.get(board_fen_only),
437
- )
438
-
439
-
440
  @app.post("/analyze-game", response_model=AnalyzeResponse)
441
  async def analyze_game(request: AnalyzeRequest):
442
- async with pool.acquire(timeout=30.0) as engine:
 
 
443
  board = chess.Board(request.start_fen) if request.start_fen else chess.Board()
444
  limit = chess.engine.Limit(time=request.time_per_move)
445
-
446
- analysis_results = []
 
 
 
 
447
  counts = {
448
- "Book": 0, "Brilliant": 0, "Great": 0, "Best": 0,
449
- "Excellent": 0, "Good": 0, "Inaccuracy": 0,
450
- "Mistake": 0, "Blunder": 0,
451
  }
452
 
453
- player_is_white = request.player_color.lower() == "white"
454
- fen_history = [board.fen()]
455
- move_history = []
456
- total_cpl = 0.0
 
457
  player_moves_count = 0
458
-
459
- infos_before = await engine.analyse(board, limit, multipv=2)
460
- if not isinstance(infos_before, list):
461
- infos_before = [infos_before]
462
  current_score, _ = get_normalized_score(infos_before[0])
463
 
464
  for i, san_move in enumerate(request.moves):
465
- is_white_turn = board.turn == chess.WHITE
466
  is_player_turn = is_white_turn if player_is_white else not is_white_turn
467
-
468
- info_dict = infos_before[0]
469
- pv_list = info_dict.get("pv", [])
470
- best_move_before = pv_list[0] if pv_list else None
471
-
472
- score_before, _ = get_normalized_score(info_dict)
473
- win_pct_before = get_win_percentage(info_dict)
474
-
475
- # Parse move early so we can compare against alt lines
476
  try:
477
  move = board.parse_san(san_move)
478
  except Exception:
479
- break
480
 
 
 
 
 
 
 
481
  alt_win_pct_before: Optional[float] = None
482
- for line in infos_before:
483
- if line.get("pv") and line["pv"][0] != move:
484
- alt_win_pct_before = get_win_percentage(line)
485
- break
 
 
486
 
487
  board_before_move = board.copy()
488
  board.push(move)
 
489
  move_history.append(move)
490
  fen_history.append(board.fen())
491
-
492
  infos_after_raw = await engine.analyse(board, limit, multipv=2)
493
  infos_after: List[dict] = infos_after_raw if isinstance(infos_after_raw, list) else [infos_after_raw]
494
- info_after_dict = infos_after[0]
495
-
496
- win_pct_after = get_win_percentage(info_after_dict)
 
497
  score_after, _ = get_normalized_score(info_after_dict)
498
- current_score = score_after
499
- best_pv_after = info_after_dict.get("pv", [])
500
-
501
- fen_two_moves_ago = None
 
502
  uci_next_two_moves = None
503
  if len(move_history) >= 2:
504
- fen_two_moves_ago = fen_history[-3]
505
  uci_next_two_moves = (move_history[-2], move_history[-1])
506
 
 
 
507
  board_fen_only = board.fen().split(" ")[0]
508
  if board_fen_only in openings_db:
509
- cls = "Book"
510
  opening_name = openings_db[board_fen_only]
511
  else:
512
  cls = get_move_classification(
@@ -519,50 +435,63 @@ async def analyze_game(request: AnalyzeRequest):
519
  fen_two_moves_ago=fen_two_moves_ago,
520
  uci_next_two_moves=uci_next_two_moves,
521
  board_before_move=board_before_move,
522
- best_pv_after=best_pv_after,
523
  )
524
- opening_name = None
525
-
526
  move_gain = score_after - score_before if is_white_turn else score_before - score_after
527
- cpl = min(max(0.0, -move_gain), 1000.0)
528
-
 
529
  if is_player_turn:
530
- total_cpl += cpl
531
  player_moves_count += 1
532
- counts[cls] = counts.get(cls, 0) + 1
533
-
534
  analysis_results.append(MoveAnalysis(
535
- move_num=i + 1,
536
  san=san_move,
 
537
  classification=cls,
538
  cpl=float(cpl),
539
  score_before=float(score_before / 100.0),
540
  score_after=float(score_after / 100.0),
541
  best_move=best_move_before.uci() if best_move_before else "",
542
- opening=opening_name,
543
  ))
544
-
545
  infos_before = infos_after
546
 
547
- avg_cpl = total_cpl / max(1, player_moves_count)
548
- accuracy = max(10.0, min(100.0, 100.0 * math.exp(-0.005 * avg_cpl)))
 
 
 
 
 
 
 
 
549
  estimated_elo = int(max(400, min(3600, round(3600 * math.exp(-0.015 * avg_cpl)))))
550
 
551
  return AnalyzeResponse(
552
  accuracy=round(accuracy, 1),
553
  estimated_elo=estimated_elo,
554
  moves=analysis_results,
555
- counts=counts,
556
  )
 
 
 
 
 
 
 
 
 
 
557
 
558
 
559
  if __name__ == "__main__":
560
  import uvicorn
561
- uvicorn.run(
562
- "main:app",
563
- host="0.0.0.0",
564
- port=7860,
565
- workers=1, # Must stay 1 β€” pool lives in-process
566
- loop="uvloop", # Faster async event loop
567
- log_level="info",
568
- )
 
 
1
  from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from pydantic import BaseModel
 
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
  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
  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
  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)
246
+ return diff > 10.0
247
 
248
  def is_simple_recapture(fen_two_moves_ago: str, previous_move: chess.Move, played_move: chess.Move) -> bool:
249
  if previous_move.to_square != played_move.to_square:
 
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
  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
  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
 
 
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:
 
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
  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)