[Python] Chess

Version 1
Requires: - Pygame - Stockfish 18 

 Which can be downloaded by running: 

 pip install pygame 

 brew install stockfish 

 

 Code: 

 """

Chess Game — Lichess-style UI (v3 — all fixes applied)

Fixes:

 1. Move list: white & black on SAME ROW (1. e4 e5)

 2. Piece rendering: platform-aware font fallback, letter-in-box if no Unicode glyphs

 3. Increment added to the player who JUST MOVED (not the opponent)

 4. Arrow keys navigate review in both directions

 5. History screen: confirm dialog before opening a saved-game review

"""

import pygame, sys, copy, random, time, json, os, platform, subprocess, threading, shutil

pygame.init()

# ─── Layout ──────────────────────────────────────────────────────────────────

BOARD_SIZE = 640

PANEL_WIDTH = 300

WINDOW_W = BOARD_SIZE + PANEL_WIDTH

WINDOW_H = BOARD_SIZE

SQ = BOARD_SIZE // 8

# ─── Colours ─────────────────────────────────────────────────────────────────

C_BG = (22, 21, 18)

C_DARK_SQ = (181, 136, 99)

C_LIGHT_SQ = (240, 217, 181)

C_HIGHLIGHT = (205, 210, 106)

C_SELECTED = (246, 246, 105)

C_PANEL = (28, 27, 24)

C_PANEL2 = (38, 37, 33)

C_PANEL3 = (52, 50, 44)

C_TEXT = (222, 220, 215)

C_TEXT2 = (140, 135, 125)

C_TEXT3 = (88, 85, 78)

C_ACCENT = (128, 196, 127)

C_GOLD = (255, 188, 66)

C_RED = (210, 90, 90)

C_BLUE = (100, 155, 220)

C_BTN = (55, 53, 47)

C_BTN_H = (75, 73, 65)

C_BTN_A = (100, 155, 220)

C_CHECK = (200, 55, 55)

C_BORDER = (65, 62, 55)

C_SEP = (50, 48, 43)

# ─── Fonts ───────────────────────────────────────────────────────────────────

FNT_XL = pygame.font.SysFont("segoeui", 32, bold=True)

FNT_LG = pygame.font.SysFont("segoeui", 22, bold=True)

FNT_MD = pygame.font.SysFont("segoeui", 17)

FNT_MDB = pygame.font.SysFont("segoeui", 17, bold=True)

FNT_SM = pygame.font.SysFont("segoeui", 14)

FNT_SMB = pygame.font.SysFont("segoeui", 14, bold=True)

FNT_XS = pygame.font.SysFont("segoeui", 12)

FNT_MONO = pygame.font.SysFont("consolas", 14)

FNT_MONO_SM = pygame.font.SysFont("consolas", 13)

FNT_CLK = pygame.font.SysFont("consolas", 28, bold=True)

# ─── Piece rendering (FIX #2) ───────────────────────────────────────────────

UNICODE_SYMS = {'K':'♔','Q':'♕','R':'♖','B':'♗','N':'♘','P':'♙',

 'k':'♚','q':'♛','r':'♜','b':'♝','n':'♞','p':'♟'}

def _make_piece_font(size):

 plat = platform.system()

 if plat == "Windows":

 cands = ["segoeuisymbol","seguisym","segoeui","arial unicode ms"]

 elif plat == "Darwin":

 cands = ["apple symbols","arial unicode ms","lucida grande"]

 else:

 cands = ["dejavusans","symbola","freesans","unifont"]

 cands += [None]

 for name in cands:

 try:

 f = pygame.font.SysFont(name, size) if name else pygame.font.Font(None, size)

 surf = f.render("♔", True, (255,255,255))

 if surf.get_width() > 4:

 return f, True

 except Exception:

 pass

 return pygame.font.SysFont("consolas", size, bold=True), False

_PF_CACHE = {}

def _pfont(size):

 if size not in _PF_CACHE:

 _PF_CACHE[size] = _make_piece_font(size)

 return _PF_CACHE[size]

def draw_piece_at(surf, piece, px, py, size=SQ):

 is_white = piece.isupper()

 fs = int(size * 0.74)

 font, use_uni = _pfont(fs)

 if not use_uni:

 pad = max(4, size//8)

 bg = (245,230,200) if is_white else (55,50,45)

 fg = (40,35,30) if is_white else (240,230,215)

 pygame.draw.rect(surf, bg,

 (px+pad, py+pad, size-pad*2, size-pad*2),

 border_radius=max(2, size//10))

 pygame.draw.rect(surf, (0,0,0),

 (px+pad, py+pad, size-pad*2, size-pad*2),

 1, border_radius=max(2, size//10))

 t = font.render(piece.upper(), True, fg)

 surf.blit(t, (px+size//2-t.get_width()//2, py+size//2-t.get_height()//2))

 return

 sym = UNICODE_SYMS.get(piece, '?')

 oc = (230,228,224) if not is_white else (28,26,22)

 for ox,oy in ((-1,0),(1,0),(0,-1),(0,1)):

 o = font.render(sym, True, oc)

 surf.blit(o, (px+size//2-o.get_width()//2+ox, py+size//2-o.get_height()//2+oy))

 clr = (255,255,255) if is_white else (22,20,18)

 t = font.render(sym, True, clr)

 surf.blit(t, (px+size//2-t.get_width()//2, py+size//2-t.get_height()//2))

# ─── Helpers ─────────────────────────────────────────────────────────────────

WHITE = 'w'; BLACK = 'b'

SAVE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "chess_history.json")

TIME_CONTROLS = [

 {"label":"5 min", "name":"Blitz", "base":300, "inc":0 },

 {"label":"10 min", "name":"Blitz", "base":600, "inc":0 },

 {"label":"15+10", "name":"Rapid", "base":900, "inc":10},

 {"label":"30 min", "name":"Rapid", "base":1800, "inc":0 },

 {"label":"60 min", "name":"Classical", "base":3600, "inc":0 },

 {"label":"90+30", "name":"Classical", "base":5400, "inc":30},

]

PIECE_VALUES = {'P':100,'N':320,'B':330,'R':500,'Q':900,'K':20000}

PST = {

'P':[ 0, 0, 0, 0, 0, 0, 0, 0,50,50,50,50,50,50,50,50,

 10,10,20,30,30,20,10,10, 5, 5,10,25,25,10, 5, 5,

 0, 0, 0,20,20, 0, 0, 0, 5,-5,-10,0,0,-10,-5, 5,

 5,10,10,-20,-20,10,10,5, 0, 0, 0, 0, 0, 0, 0, 0],

'N':[-50,-40,-30,-30,-30,-30,-40,-50,-40,-20,0,0,0,0,-20,-40,

 -30,0,10,15,15,10,0,-30,-30,5,15,20,20,15,5,-30,

 -30,0,15,20,20,15,0,-30,-30,5,10,15,15,10,5,-30,

 -40,-20,0,5,5,0,-20,-40,-50,-40,-30,-30,-30,-30,-40,-50],

'B':[-20,-10,-10,-10,-10,-10,-10,-20,-10,0,0,0,0,0,0,-10,

 -10,0,5,10,10,5,0,-10,-10,5,5,10,10,5,5,-10,

 -10,0,10,10,10,10,0,-10,-10,10,10,10,10,10,10,-10,

 -10,5,0,0,0,0,5,-10,-20,-10,-10,-10,-10,-10,-10,-20],

'R':[ 0,0,0,0,0,0,0,0, 5,10,10,10,10,10,10,5,

 -5,0,0,0,0,0,0,-5,-5,0,0,0,0,0,0,-5,

 -5,0,0,0,0,0,0,-5,-5,0,0,0,0,0,0,-5,

 -5,0,0,0,0,0,0,-5, 0,0,0,5,5,0,0,0],

'Q':[-20,-10,-10,-5,-5,-10,-10,-20,-10,0,0,0,0,0,0,-10,

 -10,0,5,5,5,5,0,-10,-5,0,5,5,5,5,0,-5,

 0,0,5,5,5,5,0,-5,-10,5,5,5,5,5,0,-10,

 -10,0,5,0,0,0,0,-10,-20,-10,-10,-5,-5,-10,-10,-20],

'K':[-30,-40,-40,-50,-50,-40,-40,-30,-30,-40,-40,-50,-50,-40,-40,-30,

 -30,-40,-40,-50,-50,-40,-40,-30,-30,-40,-40,-50,-50,-40,-40,-30,

 -20,-30,-30,-40,-40,-30,-30,-20,-10,-20,-20,-20,-20,-20,-20,-10,

 20,20,0,0,0,0,20,20,20,30,10,0,0,10,30,20],

}

def rr(surf,color,rect,r=8,brd=0,bc=None):

 pygame.draw.rect(surf,color,rect,border_radius=r)

 if brd and bc: pygame.draw.rect(surf,bc,rect,brd,border_radius=r)

def tc(surf,txt,fnt,clr,cx,cy):

 t=fnt.render(str(txt),True,clr); surf.blit(t,(cx-t.get_width()//2,cy-t.get_height()//2))

def tl(surf,txt,fnt,clr,x,y):

 t=fnt.render(str(txt),True,clr); surf.blit(t,(x,y)); return t.get_width()

def fmt(secs):

 secs=max(0,int(secs)); m,s=divmod(secs,60); return f"{m}:{s:02d}"

# ═══════════════════════════════════════════════════════════════════════════════

# Chess Engine

# ═══════════════════════════════════════════════════════════════════════════════

class ChessBoard:

 INIT = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"

 def __init__(self):

 self.board=[[None]*8 for _ in range(8)]; self.turn=WHITE

 self.castling={'K':True,'Q':True,'k':True,'q':True}

 self.ep_square=None; self.halfmove=0; self.fullmove=1

 self.history=[]; self.result=None; self.result_reason=''

 self._fen(self.INIT)

 def _fen(self,fen):

 p=fen.split()

 for r,row in enumerate(p[0].split('/')):

 c=0

 for ch in row:

 if ch.isdigit(): c+=int(ch)

 else: self.board[r][c]=ch; c+=1

 self.turn=WHITE if p[1]=='w' else BLACK

 cs=p[2]; self.castling={'K':'K' in cs,'Q':'Q' in cs,'k':'k' in cs,'q':'q' in cs}

 if p[3]!='-': self.ep_square=(8-int(p[3][1]),ord(p[3][0])-ord('a'))

 self.halfmove=int(p[4]); self.fullmove=int(p[5])

 def col(self,p): return WHITE if p and p.isupper() else (BLACK if p else None)

 def pt(self,p): return p.upper() if p else None

 def pseudo(self,row,col):

 p=self.board[row][col]

 if not p: return []

 c=self.col(p); t=self.pt(p); mv=[]

 def ok(r,c2): return 0<=r<8 and 0<=c2<8

 def slide(dirs):

 for dr,dc in dirs:

 r,c2=row+dr,col+dc

 while ok(r,c2):

 tgt=self.board[r][c2]

 if tgt is None: mv.append((r,c2))

 elif self.col(tgt)!=c: mv.append((r,c2)); break

 else: break

 r+=dr; c2+=dc

 def jump(ds):

 for dr,dc in ds:

 r,c2=row+dr,col+dc

 if ok(r,c2) and self.col(self.board[r][c2])!=c: mv.append((r,c2))

 if t=='R': slide([(1,0),(-1,0),(0,1),(0,-1)])

 elif t=='B': slide([(1,1),(1,-1),(-1,1),(-1,-1)])

 elif t=='Q': slide([(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)])

 elif t=='N': jump([(2,1),(2,-1),(-2,1),(-2,-1),(1,2),(1,-2),(-1,2),(-1,-2)])

 elif t=='K':

 jump([(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)])

 if c==WHITE and row==7 and col==4:

 if self.castling['K'] and not self.board[7][5] and not self.board[7][6]: mv.append((7,6,'cK'))

 if self.castling['Q'] and not self.board[7][3] and not self.board[7][2] and not self.board[7][1]: mv.append((7,2,'cQ'))

 elif c==BLACK and row==0 and col==4:

 if self.castling['k'] and not self.board[0][5] and not self.board[0][6]: mv.append((0,6,'ck'))

 if self.castling['q'] and not self.board[0][3] and not self.board[0][2] and not self.board[0][1]: mv.append((0,2,'cq'))

 elif t=='P':

 d=-1 if c==WHITE else 1; sr=6 if c==WHITE else 1; r2=row+d

 if ok(r2,col) and not self.board[r2][col]:

 mv.append((r2,col))

 if row==sr and not self.board[row+2*d][col]: mv.append((row+2*d,col))

 for dc in(-1,1):

 r2,c2=row+d,col+dc

 if ok(r2,c2):

 tgt=self.board[r2][c2]

 if tgt and self.col(tgt)!=c: mv.append((r2,c2))

 elif self.ep_square==(r2,c2): mv.append((r2,c2,'ep'))

 return mv

 def _chk(self,color,board):

 king='K' if color==WHITE else 'k'

 kp=next(((r,c) for r in range(8) for c in range(8) if board[r][c]==king),None)

 if not kp: return True

 orig=self.board; self.board=board

 att=any((m[0],m[1])==kp for r in range(8) for c in range(8)

 if self.col(board[r][c]) is not None and self.col(board[r][c])!=color

 for m in self.pseudo(r,c))

 self.board=orig; return att

 def _apply(self,board,castling,ep,fr,fc,tr,tc,sp=None,promo='Q'):

 b=copy.deepcopy(board); p=b[fr][fc]; c=self.col(p); t2=p.upper() if p else None

 nc=dict(castling); ne=None

 if sp in('cK','cQ','ck','cq'):

 b[tr][tc]=b[fr][fc]; b[fr][fc]=None

 rm={'cK':(7,7,7,5),'cQ':(7,0,7,3),'ck':(0,7,0,5),'cq':(0,0,0,3)}

 rr2,rc,rt,rtc2=rm[sp]; b[rt][rtc2]=b[rr2][rc]; b[rr2][rc]=None

 elif sp=='ep':

 b[tr][tc]=b[fr][fc]; b[fr][fc]=None; b[fr][tc]=None

 else:

 b[tr][tc]=b[fr][fc]; b[fr][fc]=None

 if t2=='P' and (tr==0 or tr==7): b[tr][tc]=promo if c==WHITE else promo.lower()

 if p=='K': nc['K']=nc['Q']=False

 if p=='k': nc['k']=nc['q']=False

 for sq,key in[((7,0),'Q'),((7,7),'K'),((0,0),'q'),((0,7),'k')]:

 if (fr,fc)==sq or (tr,tc)==sq: nc[key]=False

 if t2=='P' and abs(tr-fr)==2: ne=((fr+tr)//2,fc)

 return b,nc,ne

 def legal(self,row,col):

 p=self.board[row][col]

 if not p: return []

 c=self.col(p); res=[]

 for m in self.pseudo(row,col):

 tr,tc2=m[0],m[1]; sp=m[2] if len(m)>2 else None

 if sp in('cK','cQ','ck','cq'):

 if self._chk(c,self.board): continue

 mc=5 if tc2==6 else 3

 b2,_,_=self._apply(self.board,self.castling,self.ep_square,row,col,row,mc)

 if self._chk(c,b2): continue

 nb,_,_=self._apply(self.board,self.castling,self.ep_square,row,col,tr,tc2,sp)

 if not self._chk(c,nb): res.append(m)

 return res

 def all_legal(self,color=None):

 if color is None: color=self.turn

 return [(r,c)+m for r in range(8) for c in range(8)

 if self.col(self.board[r][c])==color for m in self.legal(r,c)]

 def is_check(self): return self._chk(self.turn,self.board)

 def is_checkmate(self): return not self.all_legal() and self.is_check()

 def is_stalemate(self): return not self.all_legal() and not self.is_check()

 def is_insuf(self):

 ps=[(p.upper(),r,c) for r in range(8) for c in range(8)

 if (p:=self.board[r][c]) and p.upper()!='K']

 return len(ps)==0 or (len(ps)==1 and ps[0][0] in('N','B'))

 def _san(self,fr,fc,tr,tc,sp,promo,board):

 p=board[fr][fc];

 if not p: return ''

 pt=p.upper(); CL='abcdefgh'; RL='87654321'; dest=CL[tc]+RL[tr]

 if sp in('cK','ck'): return 'O-O'

 if sp in('cQ','cq'): return 'O-O-O'

 cap='x' if board[tr][tc] or sp=='ep' else ''

 if pt=='P':

 s=(CL[fc]+cap+dest) if cap else dest

 if tr==0 or tr==7: s+='='+promo

 return s

 return pt+cap+dest

 def fen(self):

 rows=[]

 for r in range(8):

 e=0; s2=''

 for c in range(8):

 p=self.board[r][c]

 if not p: e+=1

 else:

 if e: s2+=str(e); e=0

 s2+=p

 if e: s2+=str(e)

 rows.append(s2)

 f='/'.join(rows)+' '+('w' if self.turn==WHITE else 'b')+' '

 cs=''.join(k for k in('K','Q','k','q') if self.castling[k])

 f+=(cs or '-')+' '

 if self.ep_square: f+='abcdefgh'[self.ep_square[1]]+str(8-self.ep_square[0])

 else: f+='-'

 f+=f' {self.halfmove} {self.fullmove}'

 return f

 def make_move(self,fr,fc,tr,tc,sp=None,promo='Q'):

 p=self.board[fr][fc]; cap=self.board[tr][tc]

 if sp=='ep': cap=self.board[fr][tc]

 old_b=copy.deepcopy(self.board); san=self._san(fr,fc,tr,tc,sp,promo,old_b)

 nb,nc,ne=self._apply(self.board,self.castling,self.ep_square,fr,fc,tr,tc,sp,promo)

 info={'from':(fr,fc),'to':(tr,tc),'piece':p,'captured':cap,'special':sp,'promo':promo,'san':san}

 self.history.append({'move':info,'board':old_b,'castling':dict(self.castling),

 'ep':self.ep_square,'hm':self.halfmove,'turn':self.turn})

 self.board=nb; self.castling=nc; self.ep_square=ne

 pt=p.upper() if p else None

 self.halfmove=0 if (pt=='P' or cap) else self.halfmove+1

 if self.turn==BLACK: self.fullmove+=1

 self.turn=BLACK if self.turn==WHITE else WHITE

 if self.is_checkmate(): self.result=WHITE if self.turn==BLACK else BLACK; self.result_reason='checkmate'

 elif self.is_stalemate(): self.result='draw'; self.result_reason='stalemate'

 elif self.is_insuf(): self.result='draw'; self.result_reason='insufficient material'

 elif self.halfmove>=100: self.result='draw'; self.result_reason='50-move rule'

 return info

# ═══════════════════════════════════════════════════════════════════════════════

# Fallback Python AI (used when Stockfish is unavailable)

# ═══════════════════════════════════════════════════════════════════════════════

class AI:

 D={1:1,2:1,3:2,4:2,5:3,6:3,7:4,8:4,9:5,10:6,11:6}

 N={1:350,2:250,3:180,4:120,5:70,6:40,7:20,8:8,9:2,10:0,11:0}

 def __init__(self,lv=5): self.lv=lv

 def eval(self,cb):

 s=0

 for r in range(8):

 for c in range(8):

 p=cb.board[r][c]

 if not p: continue

 pt=p.upper(); v=PIECE_VALUES.get(pt,0); idx=r*8+c if p.isupper() else (7-r)*8+c

 s+=(v+PST[pt][idx]) if p.isupper() else -(v+PST[pt][idx])

 # mobility removed for speed

 return s

 def _ord(self,cb,moves):

 def sc(m):

 fr,fc,tr,tc=m[0],m[1],m[2],m[3]; sp=m[4] if len(m)>4 else None

 cap=cb.board[tr][tc] if sp!='ep' else cb.board[m[0]][tc]

 return PIECE_VALUES.get((cap or'').upper(),0)-PIECE_VALUES.get(cb.board[fr][fc].upper(),0)//10

 return sorted(moves,key=sc,reverse=True)

 def _cl(self,cb):

 n=ChessBoard.__new__(ChessBoard)

 n.board=copy.deepcopy(cb.board); n.turn=cb.turn; n.castling=dict(cb.castling)

 n.ep_square=cb.ep_square; n.halfmove=cb.halfmove; n.fullmove=cb.fullmove

 n.history=[]; n.result=cb.result; n.result_reason=''; return n

 def _ab(self,cb,d,a,b,mx):

 if d==0 or cb.result: return self.eval(cb)

 ms=cb.all_legal()

 if not ms: return (-99999 if mx else 99999) if cb.is_check() else 0

 ms=self._ord(cb,ms)

 if mx:

 v=-999999

 for m in ms:

 c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)

 v=max(v,self._ab(c2,d-1,a,b,False)); a=max(a,v)

 if b<=a: break

 return v

 else:

 v=999999

 for m in ms:

 c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)

 v=min(v,self._ab(c2,d-1,a,b,True)); b=min(b,v)

 if b<=a: break

 return v

 def best(self,cb):

 depth=self.D.get(self.lv,3); noise=self.N.get(self.lv,0)

 ms=cb.all_legal()

 if not ms: return None

 ms=self._ord(cb,ms); mx=(cb.turn==WHITE); bv=-999999 if mx else 999999; bms=[]

 for m in ms:

 c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)

 v=self._ab(c2,depth-1,-999999,999999,not mx)+random.randint(-noise,noise)

 if(mx and v>bv)or(not mx and v<bv): bv=v; bms=[m]

 elif v==bv: bms.append(m)

 return random.choice(bms) if bms else random.choice(ms)

# ═══════════════════════════════════════════════════════════════════════════════

# Stockfish AI (async, non-blocking)

# ═══════════════════════════════════════════════════════════════════════════════

class StockfishAI:

 # Level → UCI_Elo

 LEVEL_ELO = {1:500, 2:800, 3:1200, 4:1600, 5:1800,

 6:2000, 7:2200, 8:2300, 9:2400, 10:2500, 11:2700}

 # Level → movetime (ms)

 LEVEL_TIME = {1:80, 2:100, 3:150, 4:200, 5:300,

 6:500, 7:800, 8:1200, 9:1800, 10:2500, 11:4000}

 def __init__(self, level=5):

 self.level = level

 self._proc = None

 self._thread = None

 self._result = None # (fr,fc,tr,tc,sp,promo) when ready

 self._lock = threading.Lock()

 self.ready = False

 self._init()

 # ── process management ────────────────────────────────────────────────────

 def _find(self):

 # 1) same folder as this script

 base = os.path.dirname(os.path.abspath(__file__))

 for name in ("stockfish","stockfish.exe",

 "stockfish-windows-x86-64-avx2.exe",

 "stockfish-windows-x86-64-modern.exe"):

 p = os.path.join(base, name)

 if os.path.isfile(p): return p

 # 2) system PATH (covers brew install on macOS)

 return shutil.which("stockfish")

 def _init(self):

 path = self._find()

 if not path:

 print("[Stockfish] Not found – using fallback Python AI.")

 return

 try:

 self._proc = subprocess.Popen(

 [path],

 stdin=subprocess.PIPE, stdout=subprocess.PIPE,

 stderr=subprocess.DEVNULL, text=True, bufsize=1)

 self._send("uci"); self._wait("uciok")

 self._apply_level(self.level)

 self._send("isready"); self._wait("readyok")

 self.ready = True

 print(f"[Stockfish] Ready level={self.level} "

 f"elo={self.LEVEL_ELO[self.level]}")

 except Exception as e:

 print(f"[Stockfish] Failed to start: {e}")

 self.ready = False

 def _send(self, cmd):

 if self._proc:

 self._proc.stdin.write(cmd + "\n")

 self._proc.stdin.flush()

 def _wait(self, token, timeout=10.0):

 deadline = time.time() + timeout

 while time.time() < deadline:

 line = self._proc.stdout.readline()

 if token in line: return line

 return ""

 def _apply_level(self, level):

 elo = self.LEVEL_ELO.get(level, 1750)

 self._send("setoption name UCI_LimitStrength value true")

 self._send(f"setoption name UCI_Elo value {elo}")

 def set_level(self, level):

 self.level = level

 if self.ready:

 self._apply_level(level)

 self._send("isready"); self._wait("readyok")

 def close(self):

 if self._proc:

 try: self._send("quit"); self._proc.terminate()

 except: pass

 self._proc = None

 self.ready = False

 # ── async move request ────────────────────────────────────────────────────

 def start_thinking(self, fen):

 if not self.ready: return

 with self._lock: self._result = None

 self._thread = threading.Thread(target=self._think, args=(fen,), daemon=True)

 self._thread.start()

 def _think(self, fen):

 mt = self.LEVEL_TIME.get(self.level, 500)

 self._send(f"position fen {fen}")

 self._send(f"go movetime {mt}")

 line = self._wait("bestmove", timeout=mt/1000 + 8)

 parts = line.strip().split()

 if len(parts) >= 2 and parts[0] == "bestmove" and parts[1] != "(none)":

 with self._lock:

 self._result = self._parse(parts[1])

 def is_thinking(self):

 return self._thread is not None and self._thread.is_alive()

 def get_result(self):

 """Returns move tuple or None if still thinking."""

 if self.is_thinking(): return None

 with self._lock:

 r = self._result; self._result = None

 return r

 # ── UCI string → internal move tuple ─────────────────────────────────────

 def _parse(self, uci):

 """

 'e2e4' → (6,4,4,4, None, 'Q')

 'e7e8q' → (1,4,0,4, None, 'Q') promotion

 'e1g1' → (7,4,7,6, 'cK', 'Q') castling

 """

 fc = ord(uci[0]) - ord('a')

 fr = 8 - int(uci[1])

 tc2 = ord(uci[2]) - ord('a')

 tr = 8 - int(uci[3])

 promo = uci[4].upper() if len(uci) == 5 else 'Q'

 # Castling: king moves exactly 2 squares horizontally

 sp = None

 if uci == 'e1g1': sp = 'cK'

 elif uci == 'e1c1': sp = 'cQ'

 elif uci == 'e8g8': sp = 'ck'

 elif uci == 'e8c8': sp = 'cq'

 return (fr, fc, tr, tc2, sp, promo)

# ═══════════════════════════════════════════════════════════════════════════════

# Persistence

# ═══════════════════════════════════════════════════════════════════════════════

def load_hist():

 try:

 with open(SAVE_FILE,'r') as f: return json.load(f)

 except: return []

def save_hist(recs):

 try:

 with open(SAVE_FILE,'w') as f: json.dump(recs[-10:],f,indent=2)

 except: pass

def record_game(cb,mode,ai_lv,tc_label,wt,bt):

 recs=load_hist()

 rs="White wins" if cb.result==WHITE else ("Black wins" if cb.result==BLACK else "Draw")

 recs.append({'date':time.strftime('%Y-%m-%d %H:%M'),'mode':mode,'ai_level':ai_lv,

 'tc':tc_label,'result':rs,'reason':cb.result_reason,

 'moves':[h['move']['san'] for h in cb.history],

 'white_time_left':round(wt,1),'black_time_left':round(bt,1),

 'total_moves':len(cb.history)})

 save_hist(recs)

# ═══════════════════════════════════════════════════════════════════════════════

# Screen IDs

# ═══════════════════════════════════════════════════════════════════════════════

class S:

 MAIN=0; SETUP_BOT=1; SETUP_LOC=2; PLAYING=3; PROMOTION=4

 REVIEW=5; HISTORY=6; HIST_CONFIRM=7

# ═══════════════════════════════════════════════════════════════════════════════

# Game

# ═══════════════════════════════════════════════════════════════════════════════

class Game:

 def __init__(self):

 self.surf=pygame.display.set_mode((WINDOW_W,WINDOW_H))

 pygame.display.set_caption("Chess")

 self.clk=pygame.time.Clock()

 self.state=S.MAIN

 # setup

 self.opt_ai=5; self.opt_tc=0; self.opt_col=WHITE

 # game

 self.cb=None; self.ai=StockfishAI(level=5); self.mode=None

 self.player_col=WHITE; self.flipped=False

 # clocks

 self.wt=0.0; self.bt=0.0; self.inc=0

 self.clk_last=0.0; self.clk_run=False

 # board UI

 self.sel=None; self.ltgts=[]; self.lm=None

 self.drag_p=None; self.drag_pos=None; self.drag_fr=None

 # promo

 self.promo=None

 # ai

 self.ai_busy=False

 # review

 self.rev_hist=[]; self.rev_idx=0; self.rev_boards=[]

 self.rev_src=S.PLAYING # where Back goes

 # history

 self.hist_recs=[]; self.hist_scroll=0; self.hist_confirm=None

 # notif

 self.notif=''; self.notif_exp=0

 # ─── coords ───────────────────────────────────────────────────────────────

 def s2p(self,r,c):

 return ((7-c)*SQ,(7-r)*SQ) if self.flipped else (c*SQ,r*SQ)

 def p2s(self,x,y):

 if not(0<=x<BOARD_SIZE and 0<=y<BOARD_SIZE): return None,None

 return (7-y//SQ,7-x//SQ) if self.flipped else (y//SQ,x//SQ)

 # ─── clock ────────────────────────────────────────────────────────────────

 def tick(self):

 if not self.clk_run or not self.cb or self.cb.result: return

 now=time.time(); dt=now-self.clk_last; self.clk_last=now

 if self.cb.turn==WHITE: self.wt=max(0,self.wt-dt)

 else: self.bt=max(0,self.bt-dt)

 if self.wt<=0 or self.bt<=0:

 self.cb.result=BLACK if self.wt<=0 else WHITE

 self.cb.result_reason='timeout'

 self.clk_run=False; self._end()

 # FIX #3 – increment goes to the MOVER

 def _add_inc(self,mover):

 if self.inc<=0: return

 if mover==WHITE: self.wt+=self.inc

 else: self.bt+=self.inc

 # ─── lifecycle ────────────────────────────────────────────────────────────

 def start(self,mode):

 self.mode=mode; tc=TIME_CONTROLS[self.opt_tc]

 self.wt=float(tc['base']); self.bt=float(tc['base']); self.inc=tc['inc']

 self.cb=ChessBoard()

 # Close previous Stockfish process before creating a new one

 if isinstance(self.ai, StockfishAI): self.ai.close()

 self.ai=StockfishAI(level=self.opt_ai)

 # If Stockfish unavailable, fall back to Python AI

 if not self.ai.ready: self.ai=AI(self.opt_ai)

 self.sel=None; self.ltgts=[]; self.lm=None

 self.drag_p=None; self.promo=None; self.ai_busy=False

 self.clk_run=False; self.clk_last=time.time()

 if mode=='bot':

 self.player_col=self.opt_col; self.flipped=(self.opt_col==BLACK)

 else:

 self.player_col=WHITE; self.flipped=False

 self.state=S.PLAYING

 def _end(self):

 record_game(self.cb,self.mode,self.opt_ai,

 TIME_CONTROLS[self.opt_tc]['label'],self.wt,self.bt)

 def open_review(self,history,src=S.PLAYING):

 self.rev_hist=history; self.rev_src=src

 self._build_rev()

 self.rev_idx=len(self.rev_hist); self.state=S.REVIEW

 def open_review_game(self):

 if self.cb and self.cb.history: self.open_review(self.cb.history[:],S.PLAYING)

 def _build_rev(self):

 self.rev_boards=[]

 tmp=ChessBoard(); self.rev_boards.append(copy.deepcopy(tmp.board))

 for h in self.rev_hist:

 m=h['move']

 tmp.make_move(m['from'][0],m['from'][1],m['to'][0],m['to'][1],m['special'],m['promo'])

 self.rev_boards.append(copy.deepcopy(tmp.board))

 def open_hist(self):

 self.hist_recs=load_hist(); self.hist_scroll=0; self.hist_confirm=None

 self.state=S.HISTORY

 # ─── move execution ───────────────────────────────────────────────────────

 def try_move(self,fr,fc,tr,tc2):

 legal=self.cb.legal(fr,fc)

 m=next((x for x in legal if x[0]==tr and x[1]==tc2),None)

 if m is None:

 p=self.cb.board[tr][tc2]

 if p and self.cb.col(p)==self.cb.turn:

 self.sel=(tr,tc2); self.ltgts=self.cb.legal(tr,tc2)

 else:

 self.sel=None; self.ltgts=[]

 return

 sp=m[2] if len(m)>2 else None

 if self.cb.pt(self.cb.board[fr][fc])=='P' and (tr==0 or tr==7):

 self.promo=(fr,fc,tr,tc2,sp); self.state=S.PROMOTION

 self.sel=None; self.ltgts=[]; return

 self._do(fr,fc,tr,tc2,sp,'Q')

 def _do(self,fr,fc,tr,tc2,sp,promo):

 mover=self.cb.turn

 info=self.cb.make_move(fr,fc,tr,tc2,sp,promo)

 self._add_inc(mover) # FIX #3

 self.lm=((fr,fc),(tr,tc2))

 self.sel=None; self.ltgts=[]

 self.clk_last=time.time()

 if not self.clk_run and len(self.cb.history)>=1: self.clk_run=True

 if self.cb.result: self.clk_run=False; self._end()

 self.notif=info['san']; self.notif_exp=time.time()+2.2

 def ai_tick(self):

 if self.state!=S.PLAYING or self.mode!='bot' or self.cb.result: return

 if self.cb.turn==self.player_col: return

 if isinstance(self.ai, StockfishAI):

 if not self.ai_busy:

 self.ai.start_thinking(self.cb.fen())

 self.ai_busy=True

 else:

 result=self.ai.get_result()

 if result is not None:

 fr,fc,tr,tc2,sp,promo=result

 # Re-detect castling from board if not already flagged

 # (Stockfish sends e1g1 etc. which _parse() already handles)

 # Validate the move is legal before applying

 legal=self.cb.legal(fr,fc)

 matched=next((m for m in legal if m[0]==tr and m[1]==tc2),None)

 if matched is not None:

 sp2=matched[2] if len(matched)>2 else sp

 self._do(fr,fc,tr,tc2,sp2,promo)

 self.ai_busy=False

 else:

 # Fallback Python AI (blocking but keeps working)

 if self.ai_busy: return

 self.ai_busy=True

 m=self.ai.best(self.cb); self.ai_busy=False

 if m: self._do(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None,'Q')

 # ─── find move by san for record replay ───────────────────────────────────

 def _find_san(self,cb,san):

 for m in cb.all_legal():

 fr,fc,tr,tc2=m[0],m[1],m[2],m[3]; sp=m[4] if len(m)>4 else None

 for pr in('Q','R','B','N'):

 if '=' in san and san[-1]!=pr: continue

 if cb._san(fr,fc,tr,tc2,sp,pr,cb.board)==san: return m

 return None

 def _open_rec_review(self,idx):

 recs=self.hist_recs

 if not recs or idx>=len(recs): return

 rec=recs[-(idx+1)]

 tmp=ChessBoard()

 for san in rec.get('moves',[]):

 m=self._find_san(tmp,san)

 if m is None: break

 tmp.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)

 self.open_review(tmp.history[:],S.HISTORY)

 # ─── event loop ───────────────────────────────────────────────────────────

 def run(self):

 while True:

 self.clk.tick(60); self.tick()

 for ev in pygame.event.get():

 if ev.type==pygame.QUIT:

 if isinstance(self.ai, StockfishAI): self.ai.close()

 pygame.quit(); sys.exit()

 if ev.type==pygame.KEYDOWN: self._key(ev.key)

 if ev.type==pygame.MOUSEBUTTONDOWN: self._click(ev.pos,ev.button)

 if ev.type==pygame.MOUSEBUTTONUP: self._rel(ev.pos)

 if ev.type==pygame.MOUSEMOTION:

 if self.drag_p: self.drag_pos=ev.pos

 if self.state==S.PLAYING: self.ai_tick()

 self._draw()

 # ─── key ──────────────────────────────────────────────────────────────────

 def _key(self,key):

 if self.state==S.REVIEW: # FIX #4

 if key in(pygame.K_LEFT,pygame.K_a): self.rev_idx=max(0,self.rev_idx-1)

 if key in(pygame.K_RIGHT,pygame.K_d): self.rev_idx=min(len(self.rev_hist),self.rev_idx+1)

 if key==pygame.K_HOME: self.rev_idx=0

 if key==pygame.K_END: self.rev_idx=len(self.rev_hist)

 if key==pygame.K_ESCAPE: self.state=self.rev_src

 elif self.state==S.PLAYING:

 if key==pygame.K_ESCAPE: self.state=S.MAIN

 if key==pygame.K_f: self.flipped=not self.flipped

 if key==pygame.K_r: self.open_review_game()

 elif self.state==S.HIST_CONFIRM:

 if key==pygame.K_ESCAPE: self.state=S.HISTORY

 elif key==pygame.K_ESCAPE: self.state=S.MAIN

 # ─── click dispatcher ─────────────────────────────────────────────────────

 def _click(self,pos,btn):

 {S.MAIN:self._c_main,S.SETUP_BOT:self._c_sbot,S.SETUP_LOC:self._c_sloc,

 S.PLAYING:self._c_play,S.PROMOTION:self._c_promo,S.REVIEW:self._c_rev,

 S.HISTORY:self._c_hist,S.HIST_CONFIRM:self._c_hconf

 }.get(self.state,lambda p:None)(pos)

 def _rel(self,pos):

 if self.state not in(S.PLAYING,S.PROMOTION) or not self.drag_p: return

 x,y=pos; r,c=self.p2s(x,y)

 if r is not None and self.drag_fr:

 fr,fc=self.drag_fr

 if(fr,fc)!=(r,c): self.try_move(fr,fc,r,c)

 self.drag_p=None; self.drag_pos=None; self.drag_fr=None

 def _c_main(self,pos):

 x,y=pos; cx=WINDOW_W//2

 if cx-160<=x<=cx-10 and WINDOW_H//2-30<=y<=WINDOW_H//2+30: self.state=S.SETUP_BOT

 elif cx+10<=x<=cx+160 and WINDOW_H//2-30<=y<=WINDOW_H//2+30: self.state=S.SETUP_LOC

 elif cx-100<=x<=cx+100 and WINDOW_H//2+60<=y<=WINDOW_H//2+110: self.open_hist()

 def _c_sbot(self,pos):

 x,y=pos; cx=WINDOW_W//2

 # 11 difficulty buttons — same layout as _d_sbot

 bw,bh=78,48; row1=6; row2=5

 for i in range(11):

 if i<row1:

 total_w=row1*bw+(row1-1)*6

 bx=cx-total_w//2+i*(bw+6); by=222

 else:

 j=i-row1

 total_w=row2*bw+(row2-1)*6

 bx=cx-total_w//2+j*(bw+6); by=278

 if bx<=x<=bx+bw and by<=y<=by+bh: self.opt_ai=i+1

 # Play as buttons

 if cx-160<=x<=cx-20 and 362<=y<=402: self.opt_col=WHITE

 elif cx+20<=x<=cx+160 and 362<=y<=402: self.opt_col=BLACK

 # Time controls

 for i in range(len(TIME_CONTROLS)):

 bx=cx-210+(i%3)*142; by=440+(i//3)*52

 if bx<=x<=bx+132 and by<=y<=by+42: self.opt_tc=i

 # Start

 if cx-100<=x<=cx+100 and 590<=y<=630: self.start('bot')

 # Back

 if 18<=x<=110 and 18<=y<=50: self.state=S.MAIN

 def _c_sloc(self,pos):

 x,y=pos; cx=WINDOW_W//2

 for i in range(len(TIME_CONTROLS)):

 bx=cx-210+(i%3)*142; by=262+(i//3)*52

 if bx<=x<=bx+132 and by<=y<=by+42: self.opt_tc=i

 if cx-100<=x<=cx+100 and 448<=y<=488: self.start('local')

 if 18<=x<=110 and 18<=y<=50: self.state=S.MAIN

 def _c_play(self,pos):

 x,y=pos

 if x>=BOARD_SIZE: self._c_panel(pos); return

 if self.cb.result: return

 r,c=self.p2s(x,y)

 if r is None: return

 if self.mode=='bot' and self.cb.turn!=self.player_col: return

 p=self.cb.board[r][c]

 if p and self.cb.col(p)==self.cb.turn:

 self.sel=(r,c); self.ltgts=self.cb.legal(r,c)

 self.drag_p=p; self.drag_pos=pos; self.drag_fr=(r,c)

 elif self.sel:

 self.try_move(self.sel[0],self.sel[1],r,c)

 def _c_panel(self,pos):

 x,y=pos; px=x-BOARD_SIZE

 if 558<=y<=588:

 if 10<=px<=95: self.open_review_game()

 elif 105<=px<=190: self.start(self.mode)

 elif 200<=px<=285: self.state=S.MAIN

 def _c_promo(self,pos):

 x,y=pos; cx,cy=BOARD_SIZE//2,BOARD_SIZE//2

 for i,p in enumerate(['Q','R','B','N']):

 bx=cx-105+i*54; by=cy-22

 if bx<=x<=bx+52 and by<=y<=by+50:

 fr,fc,tr,tc2,sp=self.promo

 self._do(fr,fc,tr,tc2,sp,p)

 self.promo=None; self.state=S.PLAYING; return

 def _c_rev(self,pos):

 x,y=pos; cx=BOARD_SIZE//2; by0=BOARD_SIZE-46

 for bx,by,bw,bh,act in[(cx-132,by0,40,36,'first'),(cx-84,by0,40,36,'prev'),

 (cx+44, by0,40,36,'next'),(cx+92,by0,40,36,'last')]:

 if bx<=x<=bx+bw and by<=y<=by+bh:

 if act=='first': self.rev_idx=0

 elif act=='prev': self.rev_idx=max(0,self.rev_idx-1)

 elif act=='next': self.rev_idx=min(len(self.rev_hist),self.rev_idx+1)

 elif act=='last': self.rev_idx=len(self.rev_hist)

 return

 if x>=BOARD_SIZE:

 px=x-BOARD_SIZE

 if 558<=y<=588 and 10<=px<=PANEL_WIDTH-10:

 self.state=self.rev_src

 def _c_hist(self,pos):

 x,y=pos; cx=WINDOW_W//2

 if WINDOW_H-55<=y<=WINDOW_H-20 and cx-80<=x<=cx+80:

 self.state=S.MAIN; return

 item_h=70; list_y0=88

 if 100<=y<=WINDOW_H-80 and 30<=x<=WINDOW_W-30:

 idx=(y-list_y0)//item_h+self.hist_scroll

 if 0<=idx<len(self.hist_recs):

 self.hist_confirm=idx; self.state=S.HIST_CONFIRM # FIX #5

 def _c_hconf(self,pos):

 x,y=pos; cx,cy=WINDOW_W//2,WINDOW_H//2

 if cx-120<=x<=cx-10 and cy+20<=y<=cy+65: self._open_rec_review(self.hist_confirm)

 elif cx+10<=x<=cx+120 and cy+20<=y<=cy+65: self.state=S.HISTORY

 # ═══════════════════════════════════════════════════════════════════════════

 # Drawing

 # ═══════════════════════════════════════════════════════════════════════════

 def _draw(self):

 s=self.surf; s.fill(C_BG)

 if self.state==S.MAIN: self._d_main()

 elif self.state==S.SETUP_BOT: self._d_sbot()

 elif self.state==S.SETUP_LOC: self._d_sloc()

 elif self.state in(S.PLAYING,S.PROMOTION):

 self._d_play()

 if self.state==S.PROMOTION: self._d_promo()

 elif self.state==S.REVIEW: self._d_rev()

 elif self.state==S.HISTORY: self._d_hist()

 elif self.state==S.HIST_CONFIRM: self._d_hist(); self._d_hconf()

 pygame.display.flip()

 # ── Main menu ─────────────────────────────────────────────────────────────

 def _d_main(self):

 s=self.surf; cx,cy=WINDOW_W//2,WINDOW_H//2; mx,my=pygame.mouse.get_pos()

 for r in range(8):

 for c in range(8):

 clr=(50,46,40) if(r+c)%2==0 else(38,35,30)

 pygame.draw.rect(s,clr,(c*(WINDOW_W//8),r*(WINDOW_H//8),WINDOW_W//8,WINDOW_H//8))

 ov=pygame.Surface((WINDOW_W,WINDOW_H),pygame.SRCALPHA); ov.fill((14,13,11,218)); s.blit(ov,(0,0))

 tc(s,"CHESS",FNT_XL,(235,210,150),cx,cy-155)

 tc(s,"Full Rules · 10 AI Levels · Time Controls",FNT_SM,C_TEXT2,cx,cy-112)

 pygame.draw.line(s,C_BORDER,(cx-230,cy-92),(cx+230,cy-92),1)

 for bx,by,bw,bh,lbl,clr in[(cx-160,cy-30,150,60,"vs Bot",C_BLUE),(cx+10,cy-30,150,60,"Local 2P",C_ACCENT)]:

 hov=(bx<=mx<=bx+bw and by<=my<=by+bh)

 c2=tuple(min(255,v+22) for v in clr) if hov else clr

 rr(s,c2,(bx,by,bw,bh),10,1,C_BORDER)

 tc(s,lbl,FNT_MDB,(10,10,10),bx+bw//2,by+bh//2)

 hbx,hby,hbw,hbh=cx-100,cy+60,200,50

 hov=(hbx<=mx<=hbx+hbw and hby<=my<=hby+hbh)

 rr(s,C_BTN_H if hov else C_BTN,(hbx,hby,hbw,hbh),8,1,C_BORDER)

 tc(s,"Game Records",FNT_MD,C_TEXT,hbx+hbw//2,hby+hbh//2)

 tc(s,"F: flip | R: review | ESC: menu",FNT_XS,C_TEXT3,cx,WINDOW_H-18)

 # ── Setup screens ─────────────────────────────────────────────────────────

 def _d_sbot(self):

 s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()

 tc(s,"Play vs Bot",FNT_LG,C_TEXT,cx,42)

 rr(s,C_BTN,(18,18,90,32),5,1,C_BORDER); tc(s,"< Back",FNT_SM,C_TEXT2,63,34)

 tc(s,"AI Difficulty",FNT_MD,C_TEXT2,cx,196)

 names=["Beginner","Novice","Amateur","Casual","Intermediate",

 "Advanced","Expert","Master","IM","GM","Super GM"]

 elos =[500,800,1200,1600,1800,2000,2200,2300,2400,2500,2700]

 # 11 buttons: first row 6, second row 5 — centred

 row1=6; row2=5

 bw,bh=78,48

 for i in range(11):

 if i<row1:

 total_w=row1*bw+(row1-1)*6

 bx=cx-total_w//2+i*(bw+6); by=222

 else:

 j=i-row1

 total_w=row2*bw+(row2-1)*6

 bx=cx-total_w//2+j*(bw+6); by=278

 sel=(self.opt_ai==i+1)

 c=C_BTN_A if sel else(C_BTN_H if(bx<=mx<=bx+bw and by<=my<=by+bh) else C_BTN)

 rr(s,c,(bx,by,bw,bh),7,1,C_BORDER)

 tc(s,str(i+1),FNT_SMB,(255,255,255) if sel else C_TEXT2,bx+bw//2,by+12)

 tc(s,names[i],FNT_XS,(255,255,255) if sel else C_TEXT3,bx+bw//2,by+27)

 tc(s,str(elos[i]),FNT_XS,C_GOLD if sel else C_TEXT3,bx+bw//2,by+39)

 tc(s,"Play as",FNT_MD,C_TEXT2,cx,348)

 for lbl,col2,bx in[("White",WHITE,cx-160),("Black",BLACK,cx+20)]:

 bw2,bh2=140,40; by=362; sel=(self.opt_col==col2)

 c=C_BTN_A if sel else(C_BTN_H if(bx<=mx<=bx+bw2 and by<=my<=by+bh2) else C_BTN)

 rr(s,c,(bx,by,bw2,bh2),7,1,C_BORDER)

 tc(s,lbl,FNT_MDB,(255,255,255) if sel else C_TEXT,bx+bw2//2,by+bh2//2)

 tc(s,"Time Control",FNT_MD,C_TEXT2,cx,420)

 self._d_tc(s,cx,mx,my,440)

 sbx,sby,sbw,sbh=cx-100,590,200,40; hov=(sbx<=mx<=sbx+sbw and sby<=my<=sby+sbh)

 rr(s,C_ACCENT if hov else(72,148,72),(sbx,sby,sbw,sbh),8,1,C_BORDER)

 tc(s,"Start Game",FNT_MDB,(10,30,10),sbx+sbw//2,sby+sbh//2)

 def _d_sloc(self):

 s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()

 tc(s,"Local 2-Player",FNT_LG,C_TEXT,cx,42)

 rr(s,C_BTN,(18,18,90,32),5,1,C_BORDER); tc(s,"< Back",FNT_SM,C_TEXT2,63,34)

 tc(s,"Time Control",FNT_MD,C_TEXT2,cx,238)

 self._d_tc(s,cx,mx,my,258)

 sbx,sby,sbw,sbh=cx-100,448,200,40; hov=(sbx<=mx<=sbx+sbw and sby<=my<=sby+sbh)

 rr(s,C_ACCENT if hov else(72,148,72),(sbx,sby,sbw,sbh),8,1,C_BORDER)

 tc(s,"Start Game",FNT_MDB,(10,30,10),sbx+sbw//2,sby+sbh//2)

 def _d_tc(self,s,cx,mx,my,y0):

 for i,t2 in enumerate(TIME_CONTROLS):

 bx=cx-210+(i%3)*142; by=y0+(i//3)*52; bw,bh=132,42; sel=(self.opt_tc==i)

 hov=(bx<=mx<=bx+bw and by<=my<=by+bh)

 c=C_BTN_A if sel else(C_BTN_H if hov else C_BTN)

 rr(s,c,(bx,by,bw,bh),7,1,C_BORDER)

 tc(s,t2['label'],FNT_MDB,(255,255,255) if sel else C_TEXT,bx+bw//2,by+14)

 tc(s,t2['name'], FNT_XS, (210,210,210) if sel else C_TEXT3,bx+bw//2,by+30)

 # ── Playing ───────────────────────────────────────────────────────────────

 def _d_play(self):

 self._d_board(self.surf)

 self._d_pieces(self.surf,self.cb.board if self.cb else None)

 self._d_panel(self.surf)

 self._d_drag(self.surf)

 if self.cb and self.cb.result: self._d_result(self.surf)

 def _d_board(self,s,lm=None,sel=None,tgts=None):

 fl=self.flipped

 lm =lm if lm is not None else self.lm

 sel =sel if sel is not None else self.sel

 tgts=tgts if tgts is not None else self.ltgts

 chk_sq=None

 if self.cb and self.cb.is_check() and not self.cb.result:

 king='K' if self.cb.turn==WHITE else 'k'

 for r in range(8):

 for c in range(8):

 if self.cb.board[r][c]==king: chk_sq=(r,c)

 for r in range(8):

 for c in range(8):

 px=(7-c)*SQ if fl else c*SQ; py=(7-r)*SQ if fl else r*SQ

 base=C_LIGHT_SQ if(r+c)%2==0 else C_DARK_SQ

 pygame.draw.rect(s,base,(px,py,SQ,SQ))

 if lm and((r,c)==lm[0] or(r,c)==lm[1]):

 hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_HIGHLIGHT,170)); s.blit(hl,(px,py))

 if sel and(r,c)==sel:

 hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_SELECTED[:3],210)); s.blit(hl,(px,py))

 if chk_sq and(r,c)==chk_sq:

 hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_CHECK,185)); s.blit(hl,(px,py))

 cb_b=self.cb.board if self.cb else [[None]*8 for _ in range(8)]

 for m in tgts:

 tr2,tc2=m[0],m[1]; px=(7-tc2)*SQ if fl else tc2*SQ; py=(7-tr2)*SQ if fl else tr2*SQ

 hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA)

 if cb_b[tr2][tc2]: pygame.draw.circle(hl,(0,0,0,72),(SQ//2,SQ//2),SQ//2-2,5)

 else: pygame.draw.circle(hl,(0,0,0,72),(SQ//2,SQ//2),SQ//6)

 s.blit(hl,(px,py))

 for i in range(8):

 ci=7-i if fl else i; ri=i if fl else 7-i

 lc=C_DARK_SQ if(7+i)%2==0 else C_LIGHT_SQ

 lr=C_DARK_SQ if i%2==1 else C_LIGHT_SQ

 lt=FNT_XS.render('abcdefgh'[ci],True,lc); s.blit(lt,(i*SQ+SQ-lt.get_width()-3,BOARD_SIZE-lt.get_height()-2))

 ln=FNT_XS.render(str(ri+1),True,lr); s.blit(ln,(3,i*SQ+2))

 def _d_pieces(self,s,board,fl=None):

 if board is None: return

 if fl is None: fl=self.flipped

 df=self.drag_fr

 for r in range(8):

 for c in range(8):

 if df and(r,c)==df: continue

 p=board[r][c]

 if not p: continue

 px=(7-c)*SQ if fl else c*SQ; py=(7-r)*SQ if fl else r*SQ

 draw_piece_at(s,p,px,py)

 def _d_drag(self,s):

 if self.drag_p and self.drag_pos:

 x,y=self.drag_pos; draw_piece_at(s,self.drag_p,x-SQ//2,y-SQ//2)

 def _d_result(self,s):

 ov=pygame.Surface((BOARD_SIZE,78),pygame.SRCALPHA); ov.fill((18,17,15,215))

 s.blit(ov,(0,BOARD_SIZE//2-39))

 r=self.cb.result

 msg,clr=("White wins",C_ACCENT) if r==WHITE else("Black wins",C_RED) if r==BLACK else("Draw",C_TEXT2)

 tc(s,msg,FNT_LG,clr,BOARD_SIZE//2,BOARD_SIZE//2-11)

 tc(s,self.cb.result_reason.capitalize(),FNT_SM,C_TEXT2,BOARD_SIZE//2,BOARD_SIZE//2+16)

 # ── helpers for captured pieces ───────────────────────────────────────────

 def _captured(self):

 """Return (white_captured, black_captured, advantage_color, adv_pts).

 white_captured = pieces white has taken (i.e. black pieces removed from board).

 black_captured = pieces black has taken (i.e. white pieces removed from board)."""

 PV = {'P':1,'N':3,'B':3,'R':5,'Q':9}

 start = {'P':8,'N':2,'B':2,'R':2,'Q':1}

 on_board = {}

 for r in range(8):

 for c in range(8):

 p = self.cb.board[r][c]

 if p: on_board[p] = on_board.get(p,0)+1

 # pieces white captured = missing black pieces

 w_cap=[]

 for pt,cnt in start.items():

 missing = cnt - on_board.get(pt.lower(),0)

 w_cap.extend([pt]*missing)

 # pieces black captured = missing white pieces

 b_cap=[]

 for pt,cnt in start.items():

 missing = cnt - on_board.get(pt,0)

 b_cap.extend([pt]*missing)

 ws = sum(PV.get(p,0) for p in w_cap)

 bs = sum(PV.get(p,0) for p in b_cap)

 adv = ws-bs

 return w_cap, b_cap, adv

 def _draw_caps(self, s, caps, score_adv, x, y, row_w):

 """Draw captured piece symbols in a compact row."""

 PV={'P':1,'N':3,'B':3,'R':5,'Q':9}

 # sort by value

 caps_sorted = sorted(caps, key=lambda p: PV.get(p,0))

 font, use_uni = _pfont(14)

 cx2 = x

 for p in caps_sorted:

 sym = UNICODE_SYMS.get(p.lower(),'?') if use_uni else p

 t = font.render(sym, True, C_TEXT2)

 s.blit(t,(cx2, y)); cx2 += t.get_width()+1

 if cx2 > x+row_w-20: break # don't overflow

 if score_adv > 0:

 adv_t = FNT_XS.render(f"+{score_adv}", True, C_ACCENT)

 s.blit(adv_t,(cx2+4, y+1))

 # ── Panel ─────────────────────────────────────────────────────────────────

 def _d_panel(self,s):

 px0=BOARD_SIZE; PW=PANEL_WIDTH; W=PW-28; x=px0+14; mx,my=pygame.mouse.get_pos()

 pygame.draw.rect(s,C_PANEL,(px0,0,PW,WINDOW_H))

 pygame.draw.line(s,C_BORDER,(px0,0),(px0,WINDOW_H),1)

 y=10

 # ── Header: mode + ELO ───────────────────────────────────────────────

 t2=TIME_CONTROLS[self.opt_tc]

 if self.mode=='bot':

 elo = StockfishAI.LEVEL_ELO.get(self.opt_ai, '?')

 lbl = f"vs Bot · Lv{self.opt_ai} · {elo} Elo · {t2['label']}"

 else:

 lbl = f"Local 2P · {t2['label']}"

 tc(s,lbl,FNT_XS,C_TEXT2,px0+PW//2,y+7); y+=18

 # ── Captured pieces + material advantage ─────────────────────────────

 w_cap, b_cap, adv = self._captured()

 # adv > 0 → white ahead, adv < 0 → black ahead

 # Determine which player is "opponent" (top) and "player" (bottom)

 # If playing as black: top=black(player), bottom=white(opponent)

 # Default / white / local: top=black(opponent), bottom=white(player)

 player_is_black = (self.mode=='bot' and self.player_col==BLACK)

 if player_is_black:

 # top = black (player), bottom = white (opponent)

 top_color, bot_color = BLACK, WHITE

 top_time, bot_time = self.bt, self.wt

 top_label, bot_label = "Black (You)", "White (Bot)"

 top_cap, bot_cap = b_cap, w_cap # black captured whites; white captured blacks

 top_adv = max(0,-adv) # black advantage

 bot_adv = max(0, adv) # white advantage

 else:

 top_color, bot_color = BLACK, WHITE

 top_time, bot_time = self.bt, self.wt

 if self.mode=='bot':

 top_label = "Black (Bot)"; bot_label = "White (You)"

 else:

 top_label = "Black"; bot_label = "White"

 top_cap, bot_cap = b_cap, w_cap

 top_adv = max(0,-adv)

 bot_adv = max(0, adv)

 # ── TOP player box ────────────────────────────────────────────────────

 top_act = (self.cb.turn==top_color and self.clk_run and not self.cb.result)

 rr(s,(52,50,44) if top_act else C_PANEL2,(x,y,W,44),6,1,C_BORDER)

 tc(s,top_label,FNT_XS,C_TEXT2,x+W//2,y+10)

 tc(s,fmt(top_time),FNT_CLK,C_GOLD if top_act else C_TEXT2,x+W//2,y+30)

 y+=50

 # Pieces captured BY top player = opponent's pieces they took

 # top captures opponent pieces: if top=BLACK → took white pieces (uppercase)

 # if top=WHITE → took black pieces (lowercase)

 top_caps_disp = w_cap if top_color==BLACK else b_cap # white pieces BLACK took / black pieces WHITE took

 top_adv_pts = max(0, -adv) if top_color==BLACK else max(0, adv)

 if top_caps_disp:

 self._draw_caps(s, top_caps_disp, top_adv_pts, x, y, W)

 y+=16

 pygame.draw.line(s,C_SEP,(x,y),(x+W,y),1); y+=8

 # ── Move list ─────────────────────────────────────────────────────────

 hist=self.cb.history; ROW=17; max_vis=13

 pairs=[]

 i=0

 while i<len(hist):

 ws=hist[i]['move']['san']; i+=1

 bs=hist[i]['move']['san'] if i<len(hist) else ''; i+=1

 pairs.append((len(pairs)+1,ws,bs))

 start_p=max(0,len(pairs)-max_vis); ml_y=y

 for rel,(mn,ws,bs) in enumerate(pairs[start_p:]):

 ry=ml_y+rel*ROW

 tl(s,f"{mn}.",FNT_MONO_SM,C_TEXT3,x,ry)

 tl(s,ws, FNT_MONO_SM,(225,220,205),x+30,ry)

 if bs: tl(s,bs,FNT_MONO_SM,(205,205,220),x+118,ry)

 y=ml_y+max_vis*ROW+4

 pygame.draw.line(s,C_SEP,(x,y),(x+W,y),1); y+=8

 if self.cb.is_check() and not self.cb.result:

 tc(s,"Check!",FNT_SMB,C_RED,px0+PW//2,y+8); y+=22

 if self.ai_busy:

 sf=isinstance(self.ai,StockfishAI) and self.ai.ready

 lbl2="Stockfish thinking..." if sf else "AI thinking..."

 tc(s,lbl2,FNT_SM,C_GOLD,px0+PW//2,y+8); y+=20

 if self.notif and time.time()<self.notif_exp:

 tc(s,self.notif,FNT_SM,C_ACCENT,px0+PW//2,y+8)

 # ── BOTTOM captured pieces ────────────────────────────────────────────

 bot_caps_disp = b_cap if bot_color==BLACK else w_cap # symmetric

 bot_adv_pts = max(0, -adv) if bot_color==BLACK else max(0, adv)

 bot_cap_y = 488

 if bot_caps_disp:

 self._draw_caps(s, bot_caps_disp, bot_adv_pts, x, bot_cap_y, W)

 # ── BOTTOM player box ─────────────────────────────────────────────────

 bot_act = (self.cb.turn==bot_color and self.clk_run and not self.cb.result)

 rr(s,(52,50,44) if bot_act else C_PANEL2,(x,506,W,44),6,1,C_BORDER)

 tc(s,bot_label,FNT_XS,C_TEXT2,x+W//2,506+10)

 tc(s,fmt(bot_time),FNT_CLK,C_GOLD if bot_act else C_TEXT,x+W//2,506+30)

 # ── Buttons ───────────────────────────────────────────────────────────

 for lbl2,bx,by,bw2,bh,c in[("Review",10,558,84,30,C_GOLD),

 ("Rematch",104,558,80,30,C_BTN),

 ("Menu",194,558,84,30,C_BTN)]:

 ax=px0+bx; hov=(ax<=mx<=ax+bw2 and by<=my<=by+bh)

 rr(s,C_BTN_H if hov else c,(ax,by,bw2,bh),5,1,C_BORDER)

 tc(s,lbl2,FNT_SM,C_TEXT,ax+bw2//2,by+bh//2)

 tc(s,"F:flip R:review ESC:menu",FNT_XS,C_TEXT3,px0+PW//2,WINDOW_H-10)

 def _d_promo(self):

 s=self.surf; cx,cy=BOARD_SIZE//2,BOARD_SIZE//2; mx,my=pygame.mouse.get_pos()

 ov=pygame.Surface((BOARD_SIZE,BOARD_SIZE),pygame.SRCALPHA); ov.fill((0,0,0,168)); s.blit(ov,(0,0))

 rr(s,(46,44,38),(cx-125,cy-60,250,118),12,1,C_BORDER)

 tc(s,"Promote to:",FNT_MD,C_TEXT2,cx,cy-42)

 for i,p in enumerate(['Q','R','B','N']):

 piece=p if self.cb.turn==WHITE else p.lower()

 bx=cx-105+i*54; by=cy-22; hov=(bx<=mx<=bx+52 and by<=my<=by+50)

 rr(s,C_BTN_H if hov else C_BTN,(bx,by,52,50),7,1,C_BORDER)

 draw_piece_at(s,piece,bx,by,50)

 # ── Review ────────────────────────────────────────────────────────────────

 def _d_rev(self):

 s=self.surf; idx=self.rev_idx

 board=self.rev_boards[idx] if idx<len(self.rev_boards) else(self.rev_boards[-1] if self.rev_boards else None)

 lm=None

 if idx>0: m=self.rev_hist[idx-1]['move']; lm=(m['from'],m['to'])

 # Draw board without live highlights

 old_lm=self.lm; old_sel=self.sel; old_tgts=self.ltgts

 old_cb_board=self.cb.board if self.cb else None

 self.lm=lm; self.sel=None; self.ltgts=[]

 if self.cb: self.cb.board=[[None]*8 for _ in range(8)]

 self._d_board(s)

 if self.cb and old_cb_board: self.cb.board=old_cb_board

 self.lm=old_lm; self.sel=old_sel; self.ltgts=old_tgts

 if board:

 old_df=self.drag_fr; self.drag_fr=None

 self._d_pieces(s,board); self.drag_fr=old_df

 self._d_rev_nav(s); self._d_rev_panel(s)

 def _d_rev_nav(self,s):

 cx=BOARD_SIZE//2; by0=BOARD_SIZE-46; mx,my=pygame.mouse.get_pos()

 for bx,by,bw,bh,lbl in[(cx-132,by0,40,36,'|<'),(cx-84,by0,40,36,'<'),

 (cx+44, by0,40,36,'>'),(cx+92,by0,40,36,'|>')]:

 hov=(bx<=mx<=bx+bw and by<=my<=by+bh)

 rr(s,C_BTN_H if hov else C_PANEL3,(bx,by,bw,bh),6,1,C_BORDER)

 tc(s,lbl,FNT_MDB,C_TEXT,bx+bw//2,by+bh//2)

 tc(s,f"{self.rev_idx} / {len(self.rev_hist)}",FNT_SM,C_TEXT2,cx,by0+18)

 tc(s,"Arrow keys or buttons to navigate",FNT_XS,C_TEXT3,cx,BOARD_SIZE-7)

 def _d_rev_panel(self,s):

 px0=BOARD_SIZE; PW=PANEL_WIDTH; x=px0+14; mx,my=pygame.mouse.get_pos()

 pygame.draw.rect(s,C_PANEL,(px0,0,PW,WINDOW_H))

 pygame.draw.line(s,C_BORDER,(px0,0),(px0,WINDOW_H),1)

 y=14; tc(s,"Game Review",FNT_LG,C_GOLD,px0+PW//2,y+10); y+=36

 pygame.draw.line(s,C_SEP,(x,y),(x+PW-28,y),1); y+=10

 # Paired move list in review panel (FIX #1)

 hist=self.rev_hist; cur=self.rev_idx; ROW=17; max_vis=18

 pairs=[]

 i=0

 while i<len(hist):

 ws=hist[i]['move']['san']; i+=1

 bs=hist[i]['move']['san'] if i<len(hist) else ''; i+=1

 pairs.append((len(pairs)+1,ws,bs))

 cur_row=(cur-1)//2 if cur>0 else 0

 start=max(0,cur_row-max_vis//2); end=min(len(pairs),start+max_vis); start=max(0,end-max_vis)

 for rel,(mn,ws,bs) in enumerate(pairs[start:end]):

 pi=start+rel; ry=y+rel*ROW

 w_act=(cur==pi*2+1); b_act=(cur==pi*2+2)

 if w_act: pygame.draw.rect(s,(68,65,48),(px0+8,ry-1,108,ROW),border_radius=3)

 if b_act: pygame.draw.rect(s,(68,65,48),(px0+8+118,ry-1,108,ROW),border_radius=3)

 tl(s,f"{mn}.",FNT_MONO_SM,C_TEXT3,x,ry)

 tl(s,ws, FNT_MONO_SM,C_GOLD if w_act else(225,220,205),x+30,ry)

 if bs: tl(s,bs,FNT_MONO_SM,C_GOLD if b_act else(205,205,220),x+118,ry)

 bx,by2,bw,bh=px0+10,558,PW-20,30; hov=(bx<=mx<=bx+bw and by2<=my<=by2+bh)

 rr(s,C_BTN_H if hov else C_BTN,(bx,by2,bw,bh),5,1,C_BORDER)

 back="< Back to Game" if self.rev_src==S.PLAYING else "< Back to Records"

 tc(s,back,FNT_SM,C_TEXT,px0+PW//2,by2+bh//2)

 # ── History screen ────────────────────────────────────────────────────────

 def _d_hist(self):

 s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()

 tc(s,"Game Records",FNT_LG,C_TEXT,cx,45)

 pygame.draw.line(s,C_BORDER,(40,70),(WINDOW_W-40,70),1)

 recs=self.hist_recs

 if not recs: tc(s,"No games recorded yet.",FNT_MD,C_TEXT2,cx,WINDOW_H//2)

 else:

 item_h=70; list_y0=88; vis=min(7,(WINDOW_H-170)//item_h)

 for rel in range(vis):

 idx=rel+self.hist_scroll

 if idx>=len(recs): break

 rec=recs[-(idx+1)]; ry=list_y0+rel*item_h

 hov=(30<=mx<=WINDOW_W-30 and ry<=my<=ry+item_h-3)

 bg=C_PANEL3 if hov else(C_PANEL2 if rel%2==0 else C_PANEL)

 rr(s,bg,(30,ry,WINDOW_W-60,item_h-4),6,1,C_BORDER)

 res=rec.get('result','?')

 rclr=C_ACCENT if'White' in res else(C_RED if'Black' in res else C_TEXT2)

 tl(s,res,FNT_MDB,rclr,50,ry+6)

 reason=rec.get('reason','').capitalize()

 if reason: tl(s,f"by {reason}",FNT_XS,C_TEXT2,50,ry+24)

 mode='vs Bot' if rec.get('mode')=='bot' else'Local 2P'

 lvl=f" · Lv{rec.get('ai_level','?')}" if rec.get('mode')=='bot' else''

 tl(s,f"{mode}{lvl} · {rec.get('tc','?')}",FNT_SM,C_TEXT,230,ry+8)

 tl(s,f"{rec.get('total_moves','?')} moves",FNT_XS,C_TEXT2,230,ry+26)

 tl(s,rec.get('date',''),FNT_XS,C_TEXT3,WINDOW_W-215,ry+8)

 moves=rec.get('moves',[]); prev=' '.join(f"{(i//2)+1}.{m}" if i%2==0 else m for i,m in enumerate(moves[:10]))

 if len(moves)>10: prev+='...'

 tl(s,prev,FNT_XS,C_TEXT3,50,ry+44)

 if hov: tc(s,"Click to review",FNT_XS,C_GOLD,WINDOW_W-90,ry+item_h//2-4)

 total=len(recs)

 if total>vis:

 area=WINDOW_H-170; sbh=max(20,int(vis/total*area))

 sby=list_y0+int(self.hist_scroll/max(1,total-vis)*(area-sbh))

 pygame.draw.rect(s,C_PANEL3,(WINDOW_W-14,list_y0,6,area))

 pygame.draw.rect(s,C_TEXT2,(WINDOW_W-14,sby,6,sbh),border_radius=3)

 bbx,bby,bbw,bbh=cx-80,WINDOW_H-48,160,34; hov=(bbx<=mx<=bbx+bbw and bby<=my<=bby+bbh)

 rr(s,C_BTN_H if hov else C_BTN,(bbx,bby,bbw,bbh),6,1,C_BORDER)

 tc(s,"< Main Menu",FNT_SM,C_TEXT,cx,bby+bbh//2)

 # FIX #5 – confirm overlay

 def _d_hconf(self):

 s=self.surf; cx,cy=WINDOW_W//2,WINDOW_H//2; mx,my=pygame.mouse.get_pos()

 ov=pygame.Surface((WINDOW_W,WINDOW_H),pygame.SRCALPHA); ov.fill((0,0,0,155)); s.blit(ov,(0,0))

 rr(s,(46,44,38),(cx-185,cy-80,370,170),12,1,C_BORDER)

 tc(s,"Review this game?",FNT_LG,C_TEXT,cx,cy-52)

 if self.hist_confirm is not None and self.hist_recs:

 rec=self.hist_recs[-(self.hist_confirm+1)]

 info=f"{rec.get('result','?')} · {rec.get('total_moves','?')} moves · {rec.get('date','')}"

 tc(s,info,FNT_SM,C_TEXT2,cx,cy-22)

 for lbl2,bx,c in[("Review >",cx-120,C_BLUE),("Cancel",cx+10,C_BTN)]:

 bw2,bh2=110,46; by=cy+18; hov=(bx<=mx<=bx+bw2 and by<=my<=by+bh2)

 rr(s,tuple(min(255,v+20) for v in c) if hov else c,(bx,by,bw2,bh2),8,1,C_BORDER)

 tc(s,lbl2,FNT_MDB,C_TEXT,bx+bw2//2,by+bh2//2)

# ═══════════════════════════════════════════════════════════════════════════════

if __name__ == '__main__':

 Game().run()

Version 2
Requires: 

 pip install pygame

brew install stockfish 

 Code: 

 """

Chess Game — Lichess-style UI (v3 — all fixes applied)

Fixes:

 1. Move list: white & black on SAME ROW (1. e4 e5)

 2. Piece rendering: platform-aware font fallback, letter-in-box if no Unicode glyphs

 3. Increment added to the player who JUST MOVED (not the opponent)

 4. Arrow keys navigate review in both directions

 5. History screen: confirm dialog before opening a saved-game review

"""

import pygame, sys, copy, random, time, json, os, platform, subprocess, threading, shutil

pygame.init()

# ─── Layout ──────────────────────────────────────────────────────────────────

BOARD_SIZE = 640

PANEL_WIDTH = 300

WINDOW_W = BOARD_SIZE + PANEL_WIDTH

WINDOW_H = BOARD_SIZE

SQ = BOARD_SIZE // 8

# ─── Colours ─────────────────────────────────────────────────────────────────

C_BG = (22, 21, 18)

C_DARK_SQ = (181, 136, 99)

C_LIGHT_SQ = (240, 217, 181)

C_HIGHLIGHT = (205, 210, 106)

C_SELECTED = (246, 246, 105)

C_PANEL = (28, 27, 24)

C_PANEL2 = (38, 37, 33)

C_PANEL3 = (52, 50, 44)

C_TEXT = (222, 220, 215)

C_TEXT2 = (140, 135, 125)

C_TEXT3 = (88, 85, 78)

C_ACCENT = (128, 196, 127)

C_GOLD = (255, 188, 66)

C_RED = (210, 90, 90)

C_BLUE = (100, 155, 220)

C_BTN = (55, 53, 47)

C_BTN_H = (75, 73, 65)

C_BTN_A = (100, 155, 220)

C_CHECK = (200, 55, 55)

C_BORDER = (65, 62, 55)

C_SEP = (50, 48, 43)

# ─── Move classification colours ─────────────────────────────────────────────

CLF_BRILLIANT = (0, 200, 215) # cyan

CLF_GREAT = (90, 130, 170) # blue-grey

CLF_BEST = (100, 185, 100) # green

CLF_GOOD = (160, 210, 100) # lime

CLF_MISTAKE = (220, 140, 50) # orange

CLF_BLUNDER = (210, 70, 70) # red

CLF_NONE = (80, 78, 72) # neutral

# name → (colour, symbol, description)

CLF_INFO = {

 'brilliant': (CLF_BRILLIANT, '!!', 'Brilliant — sacrifice with hidden value'),

 'great': (CLF_GREAT, '!', 'Great Move — only good reply'),

 'best': (CLF_BEST, '*', 'Best Move — top engine choice'),

 'good': (CLF_GOOD, 'v', 'Good Move — solid play'),

 'mistake': (CLF_MISTAKE, '?', 'Mistake — clear positional loss'),

 'blunder': (CLF_BLUNDER, '??', 'Blunder — game-changing error'),

 'none': (CLF_NONE, '', ''),

}

# ─── Fonts ───────────────────────────────────────────────────────────────────

FNT_XL = pygame.font.SysFont("segoeui", 32, bold=True)

FNT_LG = pygame.font.SysFont("segoeui", 22, bold=True)

FNT_MD = pygame.font.SysFont("segoeui", 17)

FNT_MDB = pygame.font.SysFont("segoeui", 17, bold=True)

FNT_SM = pygame.font.SysFont("segoeui", 14)

FNT_SMB = pygame.font.SysFont("segoeui", 14, bold=True)

FNT_XS = pygame.font.SysFont("segoeui", 12)

FNT_MONO = pygame.font.SysFont("consolas", 14)

FNT_MONO_SM = pygame.font.SysFont("consolas", 13)

FNT_CLK = pygame.font.SysFont("consolas", 28, bold=True)

# ─── Piece rendering (FIX #2) ───────────────────────────────────────────────

UNICODE_SYMS = {'K':'♔','Q':'♕','R':'♖','B':'♗','N':'♘','P':'♙',

 'k':'♚','q':'♛','r':'♜','b':'♝','n':'♞','p':'♟'}

def _make_piece_font(size):

 plat = platform.system()

 if plat == "Windows":

 cands = ["segoeuisymbol","seguisym","segoeui","arial unicode ms"]

 elif plat == "Darwin":

 cands = ["apple symbols","arial unicode ms","lucida grande"]

 else:

 cands = ["dejavusans","symbola","freesans","unifont"]

 cands += [None]

 for name in cands:

 try:

 f = pygame.font.SysFont(name, size) if name else pygame.font.Font(None, size)

 surf = f.render("♔", True, (255,255,255))

 if surf.get_width() > 4:

 return f, True

 except Exception:

 pass

 return pygame.font.SysFont("consolas", size, bold=True), False

_PF_CACHE = {}

def _pfont(size):

 if size not in _PF_CACHE:

 _PF_CACHE[size] = _make_piece_font(size)

 return _PF_CACHE[size]

def draw_piece_at(surf, piece, px, py, size=SQ):

 is_white = piece.isupper()

 fs = int(size * 0.74)

 font, use_uni = _pfont(fs)

 if not use_uni:

 pad = max(4, size//8)

 bg = (245,230,200) if is_white else (55,50,45)

 fg = (40,35,30) if is_white else (240,230,215)

 pygame.draw.rect(surf, bg,

 (px+pad, py+pad, size-pad*2, size-pad*2),

 border_radius=max(2, size//10))

 pygame.draw.rect(surf, (0,0,0),

 (px+pad, py+pad, size-pad*2, size-pad*2),

 1, border_radius=max(2, size//10))

 t = font.render(piece.upper(), True, fg)

 surf.blit(t, (px+size//2-t.get_width()//2, py+size//2-t.get_height()//2))

 return

 sym = UNICODE_SYMS.get(piece, '?')

 oc = (230,228,224) if not is_white else (28,26,22)

 for ox,oy in ((-1,0),(1,0),(0,-1),(0,1)):

 o = font.render(sym, True, oc)

 surf.blit(o, (px+size//2-o.get_width()//2+ox, py+size//2-o.get_height()//2+oy))

 clr = (255,255,255) if is_white else (22,20,18)

 t = font.render(sym, True, clr)

 surf.blit(t, (px+size//2-t.get_width()//2, py+size//2-t.get_height()//2))

# ─── Helpers ─────────────────────────────────────────────────────────────────

WHITE = 'w'; BLACK = 'b'

SAVE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "chess_history.json")

TIME_CONTROLS = [

 {"label":"5 min", "name":"Blitz", "base":300, "inc":0 },

 {"label":"10 min", "name":"Blitz", "base":600, "inc":0 },

 {"label":"15+10", "name":"Rapid", "base":900, "inc":10},

 {"label":"30 min", "name":"Rapid", "base":1800, "inc":0 },

 {"label":"60 min", "name":"Classical", "base":3600, "inc":0 },

 {"label":"90+30", "name":"Classical", "base":5400, "inc":30},

]

PIECE_VALUES = {'P':100,'N':320,'B':330,'R':500,'Q':900,'K':20000}

PST = {

'P':[ 0, 0, 0, 0, 0, 0, 0, 0,50,50,50,50,50,50,50,50,

 10,10,20,30,30,20,10,10, 5, 5,10,25,25,10, 5, 5,

 0, 0, 0,20,20, 0, 0, 0, 5,-5,-10,0,0,-10,-5, 5,

 5,10,10,-20,-20,10,10,5, 0, 0, 0, 0, 0, 0, 0, 0],

'N':[-50,-40,-30,-30,-30,-30,-40,-50,-40,-20,0,0,0,0,-20,-40,

 -30,0,10,15,15,10,0,-30,-30,5,15,20,20,15,5,-30,

 -30,0,15,20,20,15,0,-30,-30,5,10,15,15,10,5,-30,

 -40,-20,0,5,5,0,-20,-40,-50,-40,-30,-30,-30,-30,-40,-50],

'B':[-20,-10,-10,-10,-10,-10,-10,-20,-10,0,0,0,0,0,0,-10,

 -10,0,5,10,10,5,0,-10,-10,5,5,10,10,5,5,-10,

 -10,0,10,10,10,10,0,-10,-10,10,10,10,10,10,10,-10,

 -10,5,0,0,0,0,5,-10,-20,-10,-10,-10,-10,-10,-10,-20],

'R':[ 0,0,0,0,0,0,0,0, 5,10,10,10,10,10,10,5,

 -5,0,0,0,0,0,0,-5,-5,0,0,0,0,0,0,-5,

 -5,0,0,0,0,0,0,-5,-5,0,0,0,0,0,0,-5,

 -5,0,0,0,0,0,0,-5, 0,0,0,5,5,0,0,0],

'Q':[-20,-10,-10,-5,-5,-10,-10,-20,-10,0,0,0,0,0,0,-10,

 -10,0,5,5,5,5,0,-10,-5,0,5,5,5,5,0,-5,

 0,0,5,5,5,5,0,-5,-10,5,5,5,5,5,0,-10,

 -10,0,5,0,0,0,0,-10,-20,-10,-10,-5,-5,-10,-10,-20],

'K':[-30,-40,-40,-50,-50,-40,-40,-30,-30,-40,-40,-50,-50,-40,-40,-30,

 -30,-40,-40,-50,-50,-40,-40,-30,-30,-40,-40,-50,-50,-40,-40,-30,

 -20,-30,-30,-40,-40,-30,-30,-20,-10,-20,-20,-20,-20,-20,-20,-10,

 20,20,0,0,0,0,20,20,20,30,10,0,0,10,30,20],

}

def rr(surf,color,rect,r=8,brd=0,bc=None):

 pygame.draw.rect(surf,color,rect,border_radius=r)

 if brd and bc: pygame.draw.rect(surf,bc,rect,brd,border_radius=r)

def tc(surf,txt,fnt,clr,cx,cy):

 t=fnt.render(str(txt),True,clr); surf.blit(t,(cx-t.get_width()//2,cy-t.get_height()//2))

def tl(surf,txt,fnt,clr,x,y):

 t=fnt.render(str(txt),True,clr); surf.blit(t,(x,y)); return t.get_width()

def fmt(secs):

 secs=max(0,int(secs)); m,s=divmod(secs,60); return f"{m}:{s:02d}"

# ═══════════════════════════════════════════════════════════════════════════════

# Chess Engine

# ═══════════════════════════════════════════════════════════════════════════════

class ChessBoard:

 INIT = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"

 def __init__(self):

 self.board=[[None]*8 for _ in range(8)]; self.turn=WHITE

 self.castling={'K':True,'Q':True,'k':True,'q':True}

 self.ep_square=None; self.halfmove=0; self.fullmove=1

 self.history=[]; self.result=None; self.result_reason=''

 self._fen(self.INIT)

 def _fen(self,fen):

 p=fen.split()

 for r,row in enumerate(p[0].split('/')):

 c=0

 for ch in row:

 if ch.isdigit(): c+=int(ch)

 else: self.board[r][c]=ch; c+=1

 self.turn=WHITE if p[1]=='w' else BLACK

 cs=p[2]; self.castling={'K':'K' in cs,'Q':'Q' in cs,'k':'k' in cs,'q':'q' in cs}

 if p[3]!='-': self.ep_square=(8-int(p[3][1]),ord(p[3][0])-ord('a'))

 self.halfmove=int(p[4]); self.fullmove=int(p[5])

 def col(self,p): return WHITE if p and p.isupper() else (BLACK if p else None)

 def pt(self,p): return p.upper() if p else None

 def pseudo(self,row,col):

 p=self.board[row][col]

 if not p: return []

 c=self.col(p); t=self.pt(p); mv=[]

 def ok(r,c2): return 0<=r<8 and 0<=c2<8

 def slide(dirs):

 for dr,dc in dirs:

 r,c2=row+dr,col+dc

 while ok(r,c2):

 tgt=self.board[r][c2]

 if tgt is None: mv.append((r,c2))

 elif self.col(tgt)!=c: mv.append((r,c2)); break

 else: break

 r+=dr; c2+=dc

 def jump(ds):

 for dr,dc in ds:

 r,c2=row+dr,col+dc

 if ok(r,c2) and self.col(self.board[r][c2])!=c: mv.append((r,c2))

 if t=='R': slide([(1,0),(-1,0),(0,1),(0,-1)])

 elif t=='B': slide([(1,1),(1,-1),(-1,1),(-1,-1)])

 elif t=='Q': slide([(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)])

 elif t=='N': jump([(2,1),(2,-1),(-2,1),(-2,-1),(1,2),(1,-2),(-1,2),(-1,-2)])

 elif t=='K':

 jump([(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)])

 if c==WHITE and row==7 and col==4:

 if self.castling['K'] and not self.board[7][5] and not self.board[7][6]: mv.append((7,6,'cK'))

 if self.castling['Q'] and not self.board[7][3] and not self.board[7][2] and not self.board[7][1]: mv.append((7,2,'cQ'))

 elif c==BLACK and row==0 and col==4:

 if self.castling['k'] and not self.board[0][5] and not self.board[0][6]: mv.append((0,6,'ck'))

 if self.castling['q'] and not self.board[0][3] and not self.board[0][2] and not self.board[0][1]: mv.append((0,2,'cq'))

 elif t=='P':

 d=-1 if c==WHITE else 1; sr=6 if c==WHITE else 1; r2=row+d

 if ok(r2,col) and not self.board[r2][col]:

 mv.append((r2,col))

 if row==sr and not self.board[row+2*d][col]: mv.append((row+2*d,col))

 for dc in(-1,1):

 r2,c2=row+d,col+dc

 if ok(r2,c2):

 tgt=self.board[r2][c2]

 if tgt and self.col(tgt)!=c: mv.append((r2,c2))

 elif self.ep_square==(r2,c2): mv.append((r2,c2,'ep'))

 return mv

 def _chk(self,color,board):

 king='K' if color==WHITE else 'k'

 kp=next(((r,c) for r in range(8) for c in range(8) if board[r][c]==king),None)

 if not kp: return True

 orig=self.board; self.board=board

 att=any((m[0],m[1])==kp for r in range(8) for c in range(8)

 if self.col(board[r][c]) is not None and self.col(board[r][c])!=color

 for m in self.pseudo(r,c))

 self.board=orig; return att

 def _apply(self,board,castling,ep,fr,fc,tr,tc,sp=None,promo='Q'):

 b=copy.deepcopy(board); p=b[fr][fc]; c=self.col(p); t2=p.upper() if p else None

 nc=dict(castling); ne=None

 if sp in('cK','cQ','ck','cq'):

 b[tr][tc]=b[fr][fc]; b[fr][fc]=None

 rm={'cK':(7,7,7,5),'cQ':(7,0,7,3),'ck':(0,7,0,5),'cq':(0,0,0,3)}

 rr2,rc,rt,rtc2=rm[sp]; b[rt][rtc2]=b[rr2][rc]; b[rr2][rc]=None

 elif sp=='ep':

 b[tr][tc]=b[fr][fc]; b[fr][fc]=None; b[fr][tc]=None

 else:

 b[tr][tc]=b[fr][fc]; b[fr][fc]=None

 if t2=='P' and (tr==0 or tr==7): b[tr][tc]=promo if c==WHITE else promo.lower()

 if p=='K': nc['K']=nc['Q']=False

 if p=='k': nc['k']=nc['q']=False

 for sq,key in[((7,0),'Q'),((7,7),'K'),((0,0),'q'),((0,7),'k')]:

 if (fr,fc)==sq or (tr,tc)==sq: nc[key]=False

 if t2=='P' and abs(tr-fr)==2: ne=((fr+tr)//2,fc)

 return b,nc,ne

 def legal(self,row,col):

 p=self.board[row][col]

 if not p: return []

 c=self.col(p); res=[]

 for m in self.pseudo(row,col):

 tr,tc2=m[0],m[1]; sp=m[2] if len(m)>2 else None

 if sp in('cK','cQ','ck','cq'):

 if self._chk(c,self.board): continue

 mc=5 if tc2==6 else 3

 b2,_,_=self._apply(self.board,self.castling,self.ep_square,row,col,row,mc)

 if self._chk(c,b2): continue

 nb,_,_=self._apply(self.board,self.castling,self.ep_square,row,col,tr,tc2,sp)

 if not self._chk(c,nb): res.append(m)

 return res

 def all_legal(self,color=None):

 if color is None: color=self.turn

 return [(r,c)+m for r in range(8) for c in range(8)

 if self.col(self.board[r][c])==color for m in self.legal(r,c)]

 def is_check(self): return self._chk(self.turn,self.board)

 def is_checkmate(self): return not self.all_legal() and self.is_check()

 def is_stalemate(self): return not self.all_legal() and not self.is_check()

 def is_insuf(self):

 ps=[(p.upper(),r,c) for r in range(8) for c in range(8)

 if (p:=self.board[r][c]) and p.upper()!='K']

 return len(ps)==0 or (len(ps)==1 and ps[0][0] in('N','B'))

 def _san(self,fr,fc,tr,tc,sp,promo,board):

 p=board[fr][fc];

 if not p: return ''

 pt=p.upper(); CL='abcdefgh'; RL='87654321'; dest=CL[tc]+RL[tr]

 if sp in('cK','ck'): return 'O-O'

 if sp in('cQ','cq'): return 'O-O-O'

 cap='x' if board[tr][tc] or sp=='ep' else ''

 if pt=='P':

 s=(CL[fc]+cap+dest) if cap else dest

 if tr==0 or tr==7: s+='='+promo

 return s

 return pt+cap+dest

 def fen(self):

 rows=[]

 for r in range(8):

 e=0; s2=''

 for c in range(8):

 p=self.board[r][c]

 if not p: e+=1

 else:

 if e: s2+=str(e); e=0

 s2+=p

 if e: s2+=str(e)

 rows.append(s2)

 f='/'.join(rows)+' '+('w' if self.turn==WHITE else 'b')+' '

 cs=''.join(k for k in('K','Q','k','q') if self.castling[k])

 f+=(cs or '-')+' '

 if self.ep_square: f+='abcdefgh'[self.ep_square[1]]+str(8-self.ep_square[0])

 else: f+='-'

 f+=f' {self.halfmove} {self.fullmove}'

 return f

 def make_move(self,fr,fc,tr,tc,sp=None,promo='Q'):

 p=self.board[fr][fc]; cap=self.board[tr][tc]

 if sp=='ep': cap=self.board[fr][tc]

 old_b=copy.deepcopy(self.board); san=self._san(fr,fc,tr,tc,sp,promo,old_b)

 nb,nc,ne=self._apply(self.board,self.castling,self.ep_square,fr,fc,tr,tc,sp,promo)

 info={'from':(fr,fc),'to':(tr,tc),'piece':p,'captured':cap,'special':sp,'promo':promo,'san':san}

 self.history.append({'move':info,'board':old_b,'castling':dict(self.castling),

 'ep':self.ep_square,'hm':self.halfmove,'turn':self.turn})

 self.board=nb; self.castling=nc; self.ep_square=ne

 pt=p.upper() if p else None

 self.halfmove=0 if (pt=='P' or cap) else self.halfmove+1

 if self.turn==BLACK: self.fullmove+=1

 self.turn=BLACK if self.turn==WHITE else WHITE

 if self.is_checkmate(): self.result=WHITE if self.turn==BLACK else BLACK; self.result_reason='checkmate'

 elif self.is_stalemate(): self.result='draw'; self.result_reason='stalemate'

 elif self.is_insuf(): self.result='draw'; self.result_reason='insufficient material'

 elif self.halfmove>=100: self.result='draw'; self.result_reason='50-move rule'

 return info

# ═══════════════════════════════════════════════════════════════════════════════

# Fallback Python AI (used when Stockfish is unavailable)

# ═══════════════════════════════════════════════════════════════════════════════

class AI:

 D={1:1,2:1,3:2,4:2,5:3,6:3,7:4,8:4,9:5,10:6,11:6}

 N={1:350,2:250,3:180,4:120,5:70,6:40,7:20,8:8,9:2,10:0,11:0}

 def __init__(self,lv=5): self.lv=lv

 def eval(self,cb):

 s=0

 for r in range(8):

 for c in range(8):

 p=cb.board[r][c]

 if not p: continue

 pt=p.upper(); v=PIECE_VALUES.get(pt,0); idx=r*8+c if p.isupper() else (7-r)*8+c

 s+=(v+PST[pt][idx]) if p.isupper() else -(v+PST[pt][idx])

 # mobility removed for speed

 return s

 def _ord(self,cb,moves):

 def sc(m):

 fr,fc,tr,tc=m[0],m[1],m[2],m[3]; sp=m[4] if len(m)>4 else None

 cap=cb.board[tr][tc] if sp!='ep' else cb.board[m[0]][tc]

 return PIECE_VALUES.get((cap or'').upper(),0)-PIECE_VALUES.get(cb.board[fr][fc].upper(),0)//10

 return sorted(moves,key=sc,reverse=True)

 def _cl(self,cb):

 n=ChessBoard.__new__(ChessBoard)

 n.board=copy.deepcopy(cb.board); n.turn=cb.turn; n.castling=dict(cb.castling)

 n.ep_square=cb.ep_square; n.halfmove=cb.halfmove; n.fullmove=cb.fullmove

 n.history=[]; n.result=cb.result; n.result_reason=''; return n

 def _ab(self,cb,d,a,b,mx):

 if d==0 or cb.result: return self.eval(cb)

 ms=cb.all_legal()

 if not ms: return (-99999 if mx else 99999) if cb.is_check() else 0

 ms=self._ord(cb,ms)

 if mx:

 v=-999999

 for m in ms:

 c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)

 v=max(v,self._ab(c2,d-1,a,b,False)); a=max(a,v)

 if b<=a: break

 return v

 else:

 v=999999

 for m in ms:

 c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)

 v=min(v,self._ab(c2,d-1,a,b,True)); b=min(b,v)

 if b<=a: break

 return v

 def best(self,cb):

 depth=self.D.get(self.lv,3); noise=self.N.get(self.lv,0)

 ms=cb.all_legal()

 if not ms: return None

 ms=self._ord(cb,ms); mx=(cb.turn==WHITE); bv=-999999 if mx else 999999; bms=[]

 for m in ms:

 c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)

 v=self._ab(c2,depth-1,-999999,999999,not mx)+random.randint(-noise,noise)

 if(mx and v>bv)or(not mx and v<bv): bv=v; bms=[m]

 elif v==bv: bms.append(m)

 return random.choice(bms) if bms else random.choice(ms)

# ═══════════════════════════════════════════════════════════════════════════════

# Stockfish AI (async, non-blocking)

# ═══════════════════════════════════════════════════════════════════════════════

class StockfishAI:

 # Level → UCI_Elo

 LEVEL_ELO = {1:500, 2:800, 3:1200, 4:1600, 5:1800,

 6:2000, 7:2200, 8:2300, 9:2400, 10:2500, 11:2700}

 # Level → movetime (ms)

 LEVEL_TIME = {1:80, 2:100, 3:150, 4:200, 5:300,

 6:500, 7:800, 8:1200, 9:1800, 10:2500, 11:4000}

 def __init__(self, level=5):

 self.level = level

 self._proc = None

 self._thread = None

 self._result = None # (fr,fc,tr,tc,sp,promo) when ready

 self._lock = threading.Lock()

 self.ready = False

 self._init()

 # ── process management ────────────────────────────────────────────────────

 def _find(self):

 # 1) same folder as this script

 base = os.path.dirname(os.path.abspath(__file__))

 for name in ("stockfish","stockfish.exe",

 "stockfish-windows-x86-64-avx2.exe",

 "stockfish-windows-x86-64-modern.exe"):

 p = os.path.join(base, name)

 if os.path.isfile(p): return p

 # 2) system PATH (covers brew install on macOS)

 return shutil.which("stockfish")

 def _init(self):

 path = self._find()

 if not path:

 print("[Stockfish] Not found – using fallback Python AI.")

 return

 try:

 self._proc = subprocess.Popen(

 [path],

 stdin=subprocess.PIPE, stdout=subprocess.PIPE,

 stderr=subprocess.DEVNULL, text=True, bufsize=1)

 self._send("uci"); self._wait("uciok")

 self._apply_level(self.level)

 self._send("isready"); self._wait("readyok")

 self.ready = True

 print(f"[Stockfish] Ready level={self.level} "

 f"elo={self.LEVEL_ELO[self.level]}")

 except Exception as e:

 print(f"[Stockfish] Failed to start: {e}")

 self.ready = False

 def _send(self, cmd):

 if self._proc:

 self._proc.stdin.write(cmd + "\n")

 self._proc.stdin.flush()

 def _wait(self, token, timeout=10.0):

 deadline = time.time() + timeout

 while time.time() < deadline:

 line = self._proc.stdout.readline()

 if token in line: return line

 return ""

 def _apply_level(self, level):

 elo = self.LEVEL_ELO.get(level, 1750)

 self._send("setoption name UCI_LimitStrength value true")

 self._send(f"setoption name UCI_Elo value {elo}")

 def set_level(self, level):

 self.level = level

 if self.ready:

 self._apply_level(level)

 self._send("isready"); self._wait("readyok")

 def close(self):

 if self._proc:

 try: self._send("quit"); self._proc.terminate()

 except: pass

 self._proc = None

 self.ready = False

 # ── async move request ────────────────────────────────────────────────────

 def start_thinking(self, fen):

 if not self.ready: return

 with self._lock: self._result = None

 self._thread = threading.Thread(target=self._think, args=(fen,), daemon=True)

 self._thread.start()

 def _think(self, fen):

 mt = self.LEVEL_TIME.get(self.level, 500)

 self._send(f"position fen {fen}")

 self._send(f"go movetime {mt}")

 line = self._wait("bestmove", timeout=mt/1000 + 8)

 parts = line.strip().split()

 if len(parts) >= 2 and parts[0] == "bestmove" and parts[1] != "(none)":

 with self._lock:

 self._result = self._parse(parts[1])

 def is_thinking(self):

 return self._thread is not None and self._thread.is_alive()

 def get_result(self):

 """Returns move tuple or None if still thinking."""

 if self.is_thinking(): return None

 with self._lock:

 r = self._result; self._result = None

 return r

 # ── UCI string → internal move tuple ─────────────────────────────────────

 def _parse(self, uci):

 """

 'e2e4' → (6,4,4,4, None, 'Q')

 'e7e8q' → (1,4,0,4, None, 'Q') promotion

 'e1g1' → (7,4,7,6, 'cK', 'Q') castling

 """

 fc = ord(uci[0]) - ord('a')

 fr = 8 - int(uci[1])

 tc2 = ord(uci[2]) - ord('a')

 tr = 8 - int(uci[3])

 promo = uci[4].upper() if len(uci) == 5 else 'Q'

 # Castling: king moves exactly 2 squares horizontally

 sp = None

 if uci == 'e1g1': sp = 'cK'

 elif uci == 'e1c1': sp = 'cQ'

 elif uci == 'e8g8': sp = 'ck'

 elif uci == 'e8c8': sp = 'cq'

 return (fr, fc, tr, tc2, sp, promo)

# ═══════════════════════════════════════════════════════════════════════════════

# Review Analyser — uses Stockfish to score every position in a game

# ═══════════════════════════════════════════════════════════════════════════════

class ReviewAnalyser:

 """

 Runs Stockfish analysis on every position in a game (in a background thread)

 and classifies each half-move as brilliant / great / best / good / mistake / blunder.

 Results are stored as:

 self.evals : list[float] cp score (centipawns, white-positive) AFTER move i

 index 0 = initial position, index N = after move N

 self.classif: list[str] classification for move i (1-based, index 0 unused)

 self.done : bool

 """

 DEPTH = 16 # analysis depth

 ANALYSIS_MOVETIME = 300 # ms per position

 def __init__(self, sf_path):

 self.sf_path = sf_path

 self.evals = []

 self.classif = []

 self.done = False

 self._thread = None

 def analyse(self, rev_hist, rev_boards):

 """Start background analysis. rev_hist / rev_boards from _build_rev."""

 self.evals = [None] * (len(rev_hist) + 1)

 self.classif = ['none'] * (len(rev_hist) + 1)

 self.done = False

 self._thread = threading.Thread(

 target=self._run, args=(rev_hist, rev_boards), daemon=True)

 self._thread.start()

 def _score_fen(self, proc, fen):

 """Ask Stockfish for a centipawn score for this FEN. Returns float (white POV)."""

 proc.stdin.write(f"position fen {fen}\n")

 proc.stdin.write(f"go movetime {self.ANALYSIS_MOVETIME}\n")

 proc.stdin.flush()

 score = None

 mate = None

 deadline = time.time() + self.ANALYSIS_MOVETIME/1000 + 5

 while time.time() < deadline:

 line = proc.stdout.readline().strip()

 if line.startswith("info") and "score" in line:

 parts = line.split()

 try:

 si = parts.index("score")

 kind = parts[si+1]

 val = int(parts[si+2])

 if kind == "cp": score = val

 elif kind == "mate": mate = val

 except (ValueError, IndexError):

 pass

 if line.startswith("bestmove"):

 break

 if mate is not None:

 return 30000 if mate > 0 else -30000

 return float(score) if score is not None else 0.0

 def _run(self, rev_hist, rev_boards):

 path = self.sf_path

 if not path or not os.path.isfile(path):

 path = shutil.which("stockfish")

 if not path:

 self.done = True; return

 try:

 proc = subprocess.Popen(

 [path],

 stdin=subprocess.PIPE, stdout=subprocess.PIPE,

 stderr=subprocess.DEVNULL, text=True, bufsize=1)

 proc.stdin.write("uci\n"); proc.stdin.flush()

 # wait for uciok

 deadline = time.time()+10

 while time.time()<deadline:

 if "uciok" in proc.stdout.readline(): break

 proc.stdin.write("setoption name UCI_LimitStrength value false\n")

 proc.stdin.write("isready\n"); proc.stdin.flush()

 deadline = time.time()+10

 while time.time()<deadline:

 if "readyok" in proc.stdout.readline(): break

 # Build a temporary ChessBoard to replay

 tmp = ChessBoard()

 self.evals[0] = self._score_fen(proc, tmp.fen())

 for i, h in enumerate(rev_hist):

 m = h['move']

 tmp.make_move(m['from'][0],m['from'][1],

 m['to'][0], m['to'][1],

 m['special'], m['promo'])

 score = self._score_fen(proc, tmp.fen())

 self.evals[i+1] = score

 proc.stdin.write("quit\n"); proc.stdin.flush()

 proc.terminate()

 except Exception as e:

 print(f"[ReviewAnalyser] error: {e}")

 finally:

 self._classify(rev_hist)

 self.done = True

 def _classify(self, rev_hist):

 """Classify each move given the eval before and after."""

 for i, h in enumerate(rev_hist):

 before = self.evals[i]

 after = self.evals[i+1]

 if before is None or after is None:

 self.classif[i+1] = 'none'; continue

 color = h['move'].get('piece','?')

 # delta from the mover's perspective (positive = good for mover)

 if color and color.isupper(): # white moved

 delta = after - before # higher after = better for white

 else: # black moved

 delta = before - after # lower after = better for black (cp is white-POV)

 # Was the best move actually played?

 # We approximate: if delta >= -10 it's essentially best

 if delta >= -10:

 # Check for brilliant: mover sacrificed material yet eval improved

 cap = h['move'].get('captured')

 cap_val = {'P':100,'N':320,'B':330,'R':500,'Q':900}.get(

 (cap or '').upper(), 0)

 if cap_val == 0 and delta >= 50:

 self.classif[i+1] = 'brilliant'

 elif cap_val > 0 and delta >= 0:

 # sacrificed but still good → brilliant candidate

 self.classif[i+1] = 'brilliant'

 else:

 self.classif[i+1] = 'best'

 elif delta >= -30:

 self.classif[i+1] = 'great'

 elif delta >= -80:

 self.classif[i+1] = 'good'

 elif delta >= -200:

 self.classif[i+1] = 'mistake'

 else:

 self.classif[i+1] = 'blunder'

 def get_eval(self, idx):

 """Return eval at position idx (0=start), or None if not ready yet."""

 if idx < len(self.evals): return self.evals[idx]

 return None

 def get_classif(self, move_idx):

 """Return classification string for half-move move_idx (1-based)."""

 if move_idx < len(self.classif): return self.classif[move_idx]

 return 'none'

def load_hist():

 try:

 with open(SAVE_FILE,'r') as f: return json.load(f)

 except: return []

def save_hist(recs):

 try:

 with open(SAVE_FILE,'w') as f: json.dump(recs[-10:],f,indent=2)

 except: pass

def record_game(cb,mode,ai_lv,tc_label,wt,bt):

 recs=load_hist()

 rs="White wins" if cb.result==WHITE else ("Black wins" if cb.result==BLACK else "Draw")

 recs.append({'date':time.strftime('%Y-%m-%d %H:%M'),'mode':mode,'ai_level':ai_lv,

 'tc':tc_label,'result':rs,'reason':cb.result_reason,

 'moves':[h['move']['san'] for h in cb.history],

 'white_time_left':round(wt,1),'black_time_left':round(bt,1),

 'total_moves':len(cb.history)})

 save_hist(recs)

# ═══════════════════════════════════════════════════════════════════════════════

# Screen IDs

# ═══════════════════════════════════════════════════════════════════════════════

class S:

 MAIN=0; SETUP_BOT=1; SETUP_LOC=2; PLAYING=3; PROMOTION=4

 REVIEW=5; HISTORY=6; HIST_CONFIRM=7

# ═══════════════════════════════════════════════════════════════════════════════

# Game

# ═══════════════════════════════════════════════════════════════════════════════

class Game:

 def __init__(self):

 self.surf=pygame.display.set_mode((WINDOW_W,WINDOW_H))

 pygame.display.set_caption("Chess")

 self.clk=pygame.time.Clock()

 self.state=S.MAIN

 # setup

 self.opt_ai=5; self.opt_tc=0; self.opt_col=WHITE

 # game

 self.cb=None; self.ai=StockfishAI(level=5); self.mode=None

 self.player_col=WHITE; self.flipped=False

 # clocks

 self.wt=0.0; self.bt=0.0; self.inc=0

 self.clk_last=0.0; self.clk_run=False

 # board UI

 self.sel=None; self.ltgts=[]; self.lm=None

 self.drag_p=None; self.drag_pos=None; self.drag_fr=None

 # promo

 self.promo=None

 # ai

 self.ai_busy=False

 # review

 self.rev_hist=[]; self.rev_idx=0; self.rev_boards=[]

 self.rev_src=S.PLAYING # where Back goes

 self.rev_analyser=None # ReviewAnalyser instance

 # history

 self.hist_recs=[]; self.hist_scroll=0; self.hist_confirm=None

 # notif

 self.notif=''; self.notif_exp=0

 # ─── coords ───────────────────────────────────────────────────────────────

 def s2p(self,r,c):

 return ((7-c)*SQ,(7-r)*SQ) if self.flipped else (c*SQ,r*SQ)

 def p2s(self,x,y):

 if not(0<=x<BOARD_SIZE and 0<=y<BOARD_SIZE): return None,None

 return (7-y//SQ,7-x//SQ) if self.flipped else (y//SQ,x//SQ)

 # ─── clock ────────────────────────────────────────────────────────────────

 def tick(self):

 if not self.clk_run or not self.cb or self.cb.result: return

 now=time.time(); dt=now-self.clk_last; self.clk_last=now

 if self.cb.turn==WHITE: self.wt=max(0,self.wt-dt)

 else: self.bt=max(0,self.bt-dt)

 if self.wt<=0 or self.bt<=0:

 self.cb.result=BLACK if self.wt<=0 else WHITE

 self.cb.result_reason='timeout'

 self.clk_run=False; self._end()

 # FIX #3 – increment goes to the MOVER

 def _add_inc(self,mover):

 if self.inc<=0: return

 if mover==WHITE: self.wt+=self.inc

 else: self.bt+=self.inc

 # ─── lifecycle ────────────────────────────────────────────────────────────

 def start(self,mode):

 self.mode=mode; tc=TIME_CONTROLS[self.opt_tc]

 self.wt=float(tc['base']); self.bt=float(tc['base']); self.inc=tc['inc']

 self.cb=ChessBoard()

 # Close previous Stockfish process before creating a new one

 if isinstance(self.ai, StockfishAI): self.ai.close()

 self.ai=StockfishAI(level=self.opt_ai)

 # If Stockfish unavailable, fall back to Python AI

 if not self.ai.ready: self.ai=AI(self.opt_ai)

 self.sel=None; self.ltgts=[]; self.lm=None

 self.drag_p=None; self.promo=None; self.ai_busy=False

 self.clk_run=False; self.clk_last=time.time()

 if mode=='bot':

 self.player_col=self.opt_col; self.flipped=(self.opt_col==BLACK)

 else:

 self.player_col=WHITE; self.flipped=False

 self.state=S.PLAYING

 def _end(self):

 record_game(self.cb,self.mode,self.opt_ai,

 TIME_CONTROLS[self.opt_tc]['label'],self.wt,self.bt)

 def open_review(self,history,src=S.PLAYING):

 self.rev_hist=history; self.rev_src=src

 self._build_rev()

 self.rev_idx=len(self.rev_hist); self.state=S.REVIEW

 # Start Stockfish analysis in background

 sf_path = shutil.which("stockfish")

 self.rev_analyser = ReviewAnalyser(sf_path)

 self.rev_analyser.analyse(self.rev_hist, self.rev_boards)

 def open_review_game(self):

 if self.cb and self.cb.history: self.open_review(self.cb.history[:],S.PLAYING)

 def _build_rev(self):

 self.rev_boards=[]

 tmp=ChessBoard(); self.rev_boards.append(copy.deepcopy(tmp.board))

 for h in self.rev_hist:

 m=h['move']

 tmp.make_move(m['from'][0],m['from'][1],m['to'][0],m['to'][1],m['special'],m['promo'])

 self.rev_boards.append(copy.deepcopy(tmp.board))

 def open_hist(self):

 self.hist_recs=load_hist(); self.hist_scroll=0; self.hist_confirm=None

 self.state=S.HISTORY

 # ─── move execution ───────────────────────────────────────────────────────

 def try_move(self,fr,fc,tr,tc2):

 legal=self.cb.legal(fr,fc)

 m=next((x for x in legal if x[0]==tr and x[1]==tc2),None)

 if m is None:

 p=self.cb.board[tr][tc2]

 if p and self.cb.col(p)==self.cb.turn:

 self.sel=(tr,tc2); self.ltgts=self.cb.legal(tr,tc2)

 else:

 self.sel=None; self.ltgts=[]

 return

 sp=m[2] if len(m)>2 else None

 if self.cb.pt(self.cb.board[fr][fc])=='P' and (tr==0 or tr==7):

 self.promo=(fr,fc,tr,tc2,sp); self.state=S.PROMOTION

 self.sel=None; self.ltgts=[]; return

 self._do(fr,fc,tr,tc2,sp,'Q')

 def _do(self,fr,fc,tr,tc2,sp,promo):

 mover=self.cb.turn

 info=self.cb.make_move(fr,fc,tr,tc2,sp,promo)

 self._add_inc(mover) # FIX #3

 self.lm=((fr,fc),(tr,tc2))

 self.sel=None; self.ltgts=[]

 self.clk_last=time.time()

 if not self.clk_run and len(self.cb.history)>=1: self.clk_run=True

 if self.cb.result: self.clk_run=False; self._end()

 self.notif=info['san']; self.notif_exp=time.time()+2.2

 def ai_tick(self):

 if self.state!=S.PLAYING or self.mode!='bot' or self.cb.result: return

 if self.cb.turn==self.player_col: return

 if isinstance(self.ai, StockfishAI):

 # ── Stockfish path (already async) ───────────────────────────────

 if not self.ai_busy:

 self.ai.start_thinking(self.cb.fen())

 self.ai_busy=True

 else:

 result=self.ai.get_result()

 if result is not None:

 fr,fc,tr,tc2,sp,promo=result

 legal=self.cb.legal(fr,fc)

 matched=next((m for m in legal if m[0]==tr and m[1]==tc2),None)

 if matched is not None:

 sp2=matched[2] if len(matched)>2 else sp

 self._do(fr,fc,tr,tc2,sp2,promo)

 self.ai_busy=False

 else:

 # ── Fallback Python AI — run in background thread ─────────────────

 if self.ai_busy: return

 self.ai_busy=True

 # snapshot board state for the thread

 cb_snap=self.ai._cl(self.cb) # lightweight clone, no history

 def _bg():

 m=self.ai.best(cb_snap)

 self._py_ai_result=m

 self._py_ai_result=None

 t=threading.Thread(target=_bg,daemon=True); t.start()

 self._py_ai_thread=t

 def _py_ai_poll(self):

 """Called every frame to check if the Python AI thread finished."""

 if not self.ai_busy: return

 if isinstance(self.ai, StockfishAI): return

 t=getattr(self,'_py_ai_thread',None)

 if t and not t.is_alive():

 m=getattr(self,'_py_ai_result',None)

 self.ai_busy=False

 if m and self.state==S.PLAYING and not self.cb.result:

 self._do(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None,'Q')

 # ─── find move by san for record replay ───────────────────────────────────

 def _find_san(self,cb,san):

 for m in cb.all_legal():

 fr,fc,tr,tc2=m[0],m[1],m[2],m[3]; sp=m[4] if len(m)>4 else None

 for pr in('Q','R','B','N'):

 if '=' in san and san[-1]!=pr: continue

 if cb._san(fr,fc,tr,tc2,sp,pr,cb.board)==san: return m

 return None

 def _open_rec_review(self,idx):

 recs=self.hist_recs

 if not recs or idx>=len(recs): return

 rec=recs[-(idx+1)]

 tmp=ChessBoard()

 for san in rec.get('moves',[]):

 m=self._find_san(tmp,san)

 if m is None: break

 tmp.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)

 self.open_review(tmp.history[:],S.HISTORY)

 # ─── event loop ───────────────────────────────────────────────────────────

 def run(self):

 while True:

 self.clk.tick(60); self.tick()

 for ev in pygame.event.get():

 if ev.type==pygame.QUIT:

 if isinstance(self.ai, StockfishAI): self.ai.close()

 pygame.quit(); sys.exit()

 if ev.type==pygame.KEYDOWN: self._key(ev.key)

 if ev.type==pygame.MOUSEBUTTONDOWN: self._click(ev.pos,ev.button)

 if ev.type==pygame.MOUSEBUTTONUP: self._rel(ev.pos)

 if ev.type==pygame.MOUSEMOTION:

 if self.drag_p: self.drag_pos=ev.pos

 if self.state==S.PLAYING: self.ai_tick(); self._py_ai_poll()

 self._draw()

 # ─── key ──────────────────────────────────────────────────────────────────

 def _key(self,key):

 if self.state==S.REVIEW: # FIX #4

 if key in(pygame.K_LEFT,pygame.K_a): self.rev_idx=max(0,self.rev_idx-1)

 if key in(pygame.K_RIGHT,pygame.K_d): self.rev_idx=min(len(self.rev_hist),self.rev_idx+1)

 if key==pygame.K_HOME: self.rev_idx=0

 if key==pygame.K_END: self.rev_idx=len(self.rev_hist)

 if key==pygame.K_ESCAPE: self.state=self.rev_src

 elif self.state==S.PLAYING:

 if key==pygame.K_ESCAPE: self.state=S.MAIN

 if key==pygame.K_f: self.flipped=not self.flipped

 if key==pygame.K_r: self.open_review_game()

 elif self.state==S.HIST_CONFIRM:

 if key==pygame.K_ESCAPE: self.state=S.HISTORY

 elif key==pygame.K_ESCAPE: self.state=S.MAIN

 # ─── click dispatcher ─────────────────────────────────────────────────────

 def _click(self,pos,btn):

 {S.MAIN:self._c_main,S.SETUP_BOT:self._c_sbot,S.SETUP_LOC:self._c_sloc,

 S.PLAYING:self._c_play,S.PROMOTION:self._c_promo,S.REVIEW:self._c_rev,

 S.HISTORY:self._c_hist,S.HIST_CONFIRM:self._c_hconf

 }.get(self.state,lambda p:None)(pos)

 def _rel(self,pos):

 if self.state not in(S.PLAYING,S.PROMOTION) or not self.drag_p: return

 x,y=pos; r,c=self.p2s(x,y)

 if r is not None and self.drag_fr:

 fr,fc=self.drag_fr

 if(fr,fc)!=(r,c): self.try_move(fr,fc,r,c)

 self.drag_p=None; self.drag_pos=None; self.drag_fr=None

 def _c_main(self,pos):

 x,y=pos; cx=WINDOW_W//2

 if cx-160<=x<=cx-10 and WINDOW_H//2-30<=y<=WINDOW_H//2+30: self.state=S.SETUP_BOT

 elif cx+10<=x<=cx+160 and WINDOW_H//2-30<=y<=WINDOW_H//2+30: self.state=S.SETUP_LOC

 elif cx-100<=x<=cx+100 and WINDOW_H//2+60<=y<=WINDOW_H//2+110: self.open_hist()

 def _c_sbot(self,pos):

 x,y=pos; cx=WINDOW_W//2

 # 11 difficulty buttons — same layout as _d_sbot

 bw,bh=78,48; row1=6; row2=5

 for i in range(11):

 if i<row1:

 total_w=row1*bw+(row1-1)*6

 bx=cx-total_w//2+i*(bw+6); by=222

 else:

 j=i-row1

 total_w=row2*bw+(row2-1)*6

 bx=cx-total_w//2+j*(bw+6); by=278

 if bx<=x<=bx+bw and by<=y<=by+bh: self.opt_ai=i+1

 # Play as buttons

 if cx-160<=x<=cx-20 and 362<=y<=402: self.opt_col=WHITE

 elif cx+20<=x<=cx+160 and 362<=y<=402: self.opt_col=BLACK

 # Time controls

 for i in range(len(TIME_CONTROLS)):

 bx=cx-210+(i%3)*142; by=440+(i//3)*52

 if bx<=x<=bx+132 and by<=y<=by+42: self.opt_tc=i

 # Start

 if cx-100<=x<=cx+100 and 590<=y<=630: self.start('bot')

 # Back

 if 18<=x<=110 and 18<=y<=50: self.state=S.MAIN

 def _c_sloc(self,pos):

 x,y=pos; cx=WINDOW_W//2

 for i in range(len(TIME_CONTROLS)):

 bx=cx-210+(i%3)*142; by=262+(i//3)*52

 if bx<=x<=bx+132 and by<=y<=by+42: self.opt_tc=i

 if cx-100<=x<=cx+100 and 448<=y<=488: self.start('local')

 if 18<=x<=110 and 18<=y<=50: self.state=S.MAIN

 def _c_play(self,pos):

 x,y=pos

 if x>=BOARD_SIZE: self._c_panel(pos); return

 if self.cb.result: return

 r,c=self.p2s(x,y)

 if r is None: return

 if self.mode=='bot' and self.cb.turn!=self.player_col: return

 p=self.cb.board[r][c]

 if p and self.cb.col(p)==self.cb.turn:

 self.sel=(r,c); self.ltgts=self.cb.legal(r,c)

 self.drag_p=p; self.drag_pos=pos; self.drag_fr=(r,c)

 elif self.sel:

 self.try_move(self.sel[0],self.sel[1],r,c)

 def _c_panel(self,pos):

 x,y=pos; px=x-BOARD_SIZE

 if 558<=y<=588:

 if 10<=px<=95: self.open_review_game()

 elif 105<=px<=190: self.start(self.mode)

 elif 200<=px<=285: self.state=S.MAIN

 def _c_promo(self,pos):

 x,y=pos; cx,cy=BOARD_SIZE//2,BOARD_SIZE//2

 for i,p in enumerate(['Q','R','B','N']):

 bx=cx-105+i*54; by=cy-22

 if bx<=x<=bx+52 and by<=y<=by+50:

 fr,fc,tr,tc2,sp=self.promo

 self._do(fr,fc,tr,tc2,sp,p)

 self.promo=None; self.state=S.PLAYING; return

 def _c_rev(self,pos):

 x,y=pos; ox=self.BOARD_OX; cx=ox+BOARD_SIZE//2; by0=BOARD_SIZE-46

 for bx,by,bw,bh,act in[(cx-132,by0,40,36,'first'),(cx-84,by0,40,36,'prev'),

 (cx+44, by0,40,36,'next'),(cx+92,by0,40,36,'last')]:

 if bx<=x<=bx+bw and by<=y<=by+bh:

 if act=='first': self.rev_idx=0

 elif act=='prev': self.rev_idx=max(0,self.rev_idx-1)

 elif act=='next': self.rev_idx=min(len(self.rev_hist),self.rev_idx+1)

 elif act=='last': self.rev_idx=len(self.rev_hist)

 return

 panel_x=ox+BOARD_SIZE

 if x>=panel_x:

 pw=WINDOW_W-panel_x

 if 558<=y<=588 and panel_x+6<=x<=WINDOW_W-6:

 self.state=self.rev_src

 def _c_hist(self,pos):

 x,y=pos; cx=WINDOW_W//2

 if WINDOW_H-55<=y<=WINDOW_H-20 and cx-80<=x<=cx+80:

 self.state=S.MAIN; return

 item_h=70; list_y0=88

 if 100<=y<=WINDOW_H-80 and 30<=x<=WINDOW_W-30:

 idx=(y-list_y0)//item_h+self.hist_scroll

 if 0<=idx<len(self.hist_recs):

 self.hist_confirm=idx; self.state=S.HIST_CONFIRM # FIX #5

 def _c_hconf(self,pos):

 x,y=pos; cx,cy=WINDOW_W//2,WINDOW_H//2

 if cx-120<=x<=cx-10 and cy+20<=y<=cy+65: self._open_rec_review(self.hist_confirm)

 elif cx+10<=x<=cx+120 and cy+20<=y<=cy+65: self.state=S.HISTORY

 # ═══════════════════════════════════════════════════════════════════════════

 # Drawing

 # ═══════════════════════════════════════════════════════════════════════════

 def _draw(self):

 s=self.surf; s.fill(C_BG)

 if self.state==S.MAIN: self._d_main()

 elif self.state==S.SETUP_BOT: self._d_sbot()

 elif self.state==S.SETUP_LOC: self._d_sloc()

 elif self.state in(S.PLAYING,S.PROMOTION):

 self._d_play()

 if self.state==S.PROMOTION: self._d_promo()

 elif self.state==S.REVIEW: self._d_rev()

 elif self.state==S.HISTORY: self._d_hist()

 elif self.state==S.HIST_CONFIRM: self._d_hist(); self._d_hconf()

 pygame.display.flip()

 # ── Main menu ─────────────────────────────────────────────────────────────

 def _d_main(self):

 s=self.surf; cx,cy=WINDOW_W//2,WINDOW_H//2; mx,my=pygame.mouse.get_pos()

 for r in range(8):

 for c in range(8):

 clr=(50,46,40) if(r+c)%2==0 else(38,35,30)

 pygame.draw.rect(s,clr,(c*(WINDOW_W//8),r*(WINDOW_H//8),WINDOW_W//8,WINDOW_H//8))

 ov=pygame.Surface((WINDOW_W,WINDOW_H),pygame.SRCALPHA); ov.fill((14,13,11,218)); s.blit(ov,(0,0))

 tc(s,"CHESS",FNT_XL,(235,210,150),cx,cy-155)

 tc(s,"Full Rules · 10 AI Levels · Time Controls",FNT_SM,C_TEXT2,cx,cy-112)

 pygame.draw.line(s,C_BORDER,(cx-230,cy-92),(cx+230,cy-92),1)

 for bx,by,bw,bh,lbl,clr in[(cx-160,cy-30,150,60,"vs Bot",C_BLUE),(cx+10,cy-30,150,60,"Local 2P",C_ACCENT)]:

 hov=(bx<=mx<=bx+bw and by<=my<=by+bh)

 c2=tuple(min(255,v+22) for v in clr) if hov else clr

 rr(s,c2,(bx,by,bw,bh),10,1,C_BORDER)

 tc(s,lbl,FNT_MDB,(10,10,10),bx+bw//2,by+bh//2)

 hbx,hby,hbw,hbh=cx-100,cy+60,200,50

 hov=(hbx<=mx<=hbx+hbw and hby<=my<=hby+hbh)

 rr(s,C_BTN_H if hov else C_BTN,(hbx,hby,hbw,hbh),8,1,C_BORDER)

 tc(s,"Game Records",FNT_MD,C_TEXT,hbx+hbw//2,hby+hbh//2)

 tc(s,"F: flip | R: review | ESC: menu",FNT_XS,C_TEXT3,cx,WINDOW_H-18)

 # ── Setup screens ─────────────────────────────────────────────────────────

 def _d_sbot(self):

 s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()

 tc(s,"Play vs Bot",FNT_LG,C_TEXT,cx,42)

 rr(s,C_BTN,(18,18,90,32),5,1,C_BORDER); tc(s,"< Back",FNT_SM,C_TEXT2,63,34)

 tc(s,"AI Difficulty",FNT_MD,C_TEXT2,cx,196)

 names=["Beginner","Novice","Amateur","Casual","Intermediate",

 "Advanced","Expert","Master","IM","GM","Super GM"]

 elos =[500,800,1200,1600,1800,2000,2200,2300,2400,2500,2700]

 # 11 buttons: first row 6, second row 5 — centred

 row1=6; row2=5

 bw,bh=78,48

 for i in range(11):

 if i<row1:

 total_w=row1*bw+(row1-1)*6

 bx=cx-total_w//2+i*(bw+6); by=222

 else:

 j=i-row1

 total_w=row2*bw+(row2-1)*6

 bx=cx-total_w//2+j*(bw+6); by=278

 sel=(self.opt_ai==i+1)

 c=C_BTN_A if sel else(C_BTN_H if(bx<=mx<=bx+bw and by<=my<=by+bh) else C_BTN)

 rr(s,c,(bx,by,bw,bh),7,1,C_BORDER)

 tc(s,str(i+1),FNT_SMB,(255,255,255) if sel else C_TEXT2,bx+bw//2,by+12)

 tc(s,names[i],FNT_XS,(255,255,255) if sel else C_TEXT3,bx+bw//2,by+27)

 tc(s,str(elos[i]),FNT_XS,C_GOLD if sel else C_TEXT3,bx+bw//2,by+39)

 tc(s,"Play as",FNT_MD,C_TEXT2,cx,348)

 for lbl,col2,bx in[("White",WHITE,cx-160),("Black",BLACK,cx+20)]:

 bw2,bh2=140,40; by=362; sel=(self.opt_col==col2)

 c=C_BTN_A if sel else(C_BTN_H if(bx<=mx<=bx+bw2 and by<=my<=by+bh2) else C_BTN)

 rr(s,c,(bx,by,bw2,bh2),7,1,C_BORDER)

 tc(s,lbl,FNT_MDB,(255,255,255) if sel else C_TEXT,bx+bw2//2,by+bh2//2)

 tc(s,"Time Control",FNT_MD,C_TEXT2,cx,420)

 self._d_tc(s,cx,mx,my,440)

 sbx,sby,sbw,sbh=cx-100,590,200,40; hov=(sbx<=mx<=sbx+sbw and sby<=my<=sby+sbh)

 rr(s,C_ACCENT if hov else(72,148,72),(sbx,sby,sbw,sbh),8,1,C_BORDER)

 tc(s,"Start Game",FNT_MDB,(10,30,10),sbx+sbw//2,sby+sbh//2)

 def _d_sloc(self):

 s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()

 tc(s,"Local 2-Player",FNT_LG,C_TEXT,cx,42)

 rr(s,C_BTN,(18,18,90,32),5,1,C_BORDER); tc(s,"< Back",FNT_SM,C_TEXT2,63,34)

 tc(s,"Time Control",FNT_MD,C_TEXT2,cx,238)

 self._d_tc(s,cx,mx,my,258)

 sbx,sby,sbw,sbh=cx-100,448,200,40; hov=(sbx<=mx<=sbx+sbw and sby<=my<=sby+sbh)

 rr(s,C_ACCENT if hov else(72,148,72),(sbx,sby,sbw,sbh),8,1,C_BORDER)

 tc(s,"Start Game",FNT_MDB,(10,30,10),sbx+sbw//2,sby+sbh//2)

 def _d_tc(self,s,cx,mx,my,y0):

 for i,t2 in enumerate(TIME_CONTROLS):

 bx=cx-210+(i%3)*142; by=y0+(i//3)*52; bw,bh=132,42; sel=(self.opt_tc==i)

 hov=(bx<=mx<=bx+bw and by<=my<=by+bh)

 c=C_BTN_A if sel else(C_BTN_H if hov else C_BTN)

 rr(s,c,(bx,by,bw,bh),7,1,C_BORDER)

 tc(s,t2['label'],FNT_MDB,(255,255,255) if sel else C_TEXT,bx+bw//2,by+14)

 tc(s,t2['name'], FNT_XS, (210,210,210) if sel else C_TEXT3,bx+bw//2,by+30)

 # ── Playing ───────────────────────────────────────────────────────────────

 def _d_play(self):

 self._d_board(self.surf)

 self._d_pieces(self.surf,self.cb.board if self.cb else None)

 self._d_panel(self.surf)

 self._d_drag(self.surf)

 if self.cb and self.cb.result: self._d_result(self.surf)

 def _d_board(self,s,lm=None,sel=None,tgts=None):

 fl=self.flipped

 lm =lm if lm is not None else self.lm

 sel =sel if sel is not None else self.sel

 tgts=tgts if tgts is not None else self.ltgts

 chk_sq=None

 if self.cb and self.cb.is_check() and not self.cb.result:

 king='K' if self.cb.turn==WHITE else 'k'

 for r in range(8):

 for c in range(8):

 if self.cb.board[r][c]==king: chk_sq=(r,c)

 for r in range(8):

 for c in range(8):

 px=(7-c)*SQ if fl else c*SQ; py=(7-r)*SQ if fl else r*SQ

 base=C_LIGHT_SQ if(r+c)%2==0 else C_DARK_SQ

 pygame.draw.rect(s,base,(px,py,SQ,SQ))

 if lm and((r,c)==lm[0] or(r,c)==lm[1]):

 hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_HIGHLIGHT,170)); s.blit(hl,(px,py))

 if sel and(r,c)==sel:

 hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_SELECTED[:3],210)); s.blit(hl,(px,py))

 if chk_sq and(r,c)==chk_sq:

 hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_CHECK,185)); s.blit(hl,(px,py))

 cb_b=self.cb.board if self.cb else [[None]*8 for _ in range(8)]

 for m in tgts:

 tr2,tc2=m[0],m[1]; px=(7-tc2)*SQ if fl else tc2*SQ; py=(7-tr2)*SQ if fl else tr2*SQ

 hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA)

 if cb_b[tr2][tc2]: pygame.draw.circle(hl,(0,0,0,72),(SQ//2,SQ//2),SQ//2-2,5)

 else: pygame.draw.circle(hl,(0,0,0,72),(SQ//2,SQ//2),SQ//6)

 s.blit(hl,(px,py))

 for i in range(8):

 ci=7-i if fl else i; ri=i if fl else 7-i

 lc=C_DARK_SQ if(7+i)%2==0 else C_LIGHT_SQ

 lr=C_DARK_SQ if i%2==1 else C_LIGHT_SQ

 lt=FNT_XS.render('abcdefgh'[ci],True,lc); s.blit(lt,(i*SQ+SQ-lt.get_width()-3,BOARD_SIZE-lt.get_height()-2))

 ln=FNT_XS.render(str(ri+1),True,lr); s.blit(ln,(3,i*SQ+2))

 def _d_pieces(self,s,board,fl=None):

 if board is None: return

 if fl is None: fl=self.flipped

 df=self.drag_fr

 for r in range(8):

 for c in range(8):

 if df and(r,c)==df: continue

 p=board[r][c]

 if not p: continue

 px=(7-c)*SQ if fl else c*SQ; py=(7-r)*SQ if fl else r*SQ

 draw_piece_at(s,p,px,py)

 def _d_drag(self,s):

 if self.drag_p and self.drag_pos:

 x,y=self.drag_pos; draw_piece_at(s,self.drag_p,x-SQ//2,y-SQ//2)

 def _d_result(self,s):

 ov=pygame.Surface((BOARD_SIZE,78),pygame.SRCALPHA); ov.fill((18,17,15,215))

 s.blit(ov,(0,BOARD_SIZE//2-39))

 r=self.cb.result

 msg,clr=("White wins",C_ACCENT) if r==WHITE else("Black wins",C_RED) if r==BLACK else("Draw",C_TEXT2)

 tc(s,msg,FNT_LG,clr,BOARD_SIZE//2,BOARD_SIZE//2-11)

 tc(s,self.cb.result_reason.capitalize(),FNT_SM,C_TEXT2,BOARD_SIZE//2,BOARD_SIZE//2+16)

 # ── helpers for captured pieces ───────────────────────────────────────────

 def _captured(self):

 """Return (white_captured, black_captured, advantage_color, adv_pts).

 white_captured = pieces white has taken (i.e. black pieces removed from board).

 black_captured = pieces black has taken (i.e. white pieces removed from board)."""

 PV = {'P':1,'N':3,'B':3,'R':5,'Q':9}

 start = {'P':8,'N':2,'B':2,'R':2,'Q':1}

 on_board = {}

 for r in range(8):

 for c in range(8):

 p = self.cb.board[r][c]

 if p: on_board[p] = on_board.get(p,0)+1

 # pieces white captured = missing black pieces

 w_cap=[]

 for pt,cnt in start.items():

 missing = cnt - on_board.get(pt.lower(),0)

 w_cap.extend([pt]*missing)

 # pieces black captured = missing white pieces

 b_cap=[]

 for pt,cnt in start.items():

 missing = cnt - on_board.get(pt,0)

 b_cap.extend([pt]*missing)

 ws = sum(PV.get(p,0) for p in w_cap)

 bs = sum(PV.get(p,0) for p in b_cap)

 adv = ws-bs

 return w_cap, b_cap, adv

 def _draw_caps(self, s, caps, score_adv, x, y, row_w):

 """Draw captured piece symbols in a compact row."""

 PV={'P':1,'N':3,'B':3,'R':5,'Q':9}

 # sort by value

 caps_sorted = sorted(caps, key=lambda p: PV.get(p,0))

 font, use_uni = _pfont(14)

 cx2 = x

 for p in caps_sorted:

 sym = UNICODE_SYMS.get(p.lower(),'?') if use_uni else p

 t = font.render(sym, True, C_TEXT2)

 s.blit(t,(cx2, y)); cx2 += t.get_width()+1

 if cx2 > x+row_w-20: break # don't overflow

 if score_adv > 0:

 adv_t = FNT_XS.render(f"+{score_adv}", True, C_ACCENT)

 s.blit(adv_t,(cx2+4, y+1))

 # ── Panel ─────────────────────────────────────────────────────────────────

 def _d_panel(self,s):

 px0=BOARD_SIZE; PW=PANEL_WIDTH; W=PW-28; x=px0+14; mx,my=pygame.mouse.get_pos()

 pygame.draw.rect(s,C_PANEL,(px0,0,PW,WINDOW_H))

 pygame.draw.line(s,C_BORDER,(px0,0),(px0,WINDOW_H),1)

 y=10

 # ── Header: mode + ELO ───────────────────────────────────────────────

 t2=TIME_CONTROLS[self.opt_tc]

 if self.mode=='bot':

 elo = StockfishAI.LEVEL_ELO.get(self.opt_ai, '?')

 lbl = f"vs Bot · Lv{self.opt_ai} · {elo} Elo · {t2['label']}"

 else:

 lbl = f"Local 2P · {t2['label']}"

 tc(s,lbl,FNT_XS,C_TEXT2,px0+PW//2,y+7); y+=18

 # ── Captured pieces + material advantage ─────────────────────────────

 w_cap, b_cap, adv = self._captured()

 # adv > 0 → white ahead, adv < 0 → black ahead

 # Determine which player is "opponent" (top) and "player" (bottom)

 # If playing as black: top=black(player), bottom=white(opponent)

 # Default / white / local: top=black(opponent), bottom=white(player)

 player_is_black = (self.mode=='bot' and self.player_col==BLACK)

 if player_is_black:

 # top = black (player), bottom = white (opponent)

 top_color, bot_color = BLACK, WHITE

 top_time, bot_time = self.bt, self.wt

 top_label, bot_label = "Black (You)", "White (Bot)"

 top_cap, bot_cap = b_cap, w_cap # black captured whites; white captured blacks

 top_adv = max(0,-adv) # black advantage

 bot_adv = max(0, adv) # white advantage

 else:

 top_color, bot_color = BLACK, WHITE

 top_time, bot_time = self.bt, self.wt

 if self.mode=='bot':

 top_label = "Black (Bot)"; bot_label = "White (You)"

 else:

 top_label = "Black"; bot_label = "White"

 top_cap, bot_cap = b_cap, w_cap

 top_adv = max(0,-adv)

 bot_adv = max(0, adv)

 # ── TOP player box ────────────────────────────────────────────────────

 top_act = (self.cb.turn==top_color and self.clk_run and not self.cb.result)

 rr(s,(52,50,44) if top_act else C_PANEL2,(x,y,W,44),6,1,C_BORDER)

 tc(s,top_label,FNT_XS,C_TEXT2,x+W//2,y+10)

 tc(s,fmt(top_time),FNT_CLK,C_GOLD if top_act else C_TEXT2,x+W//2,y+30)

 y+=50

 # Pieces captured BY top player = opponent's pieces they took

 # top captures opponent pieces: if top=BLACK → took white pieces (uppercase)

 # if top=WHITE → took black pieces (lowercase)

 top_caps_disp = w_cap if top_color==BLACK else b_cap # white pieces BLACK took / black pieces WHITE took

 top_adv_pts = max(0, -adv) if top_color==BLACK else max(0, adv)

 if top_caps_disp:

 self._draw_caps(s, top_caps_disp, top_adv_pts, x, y, W)

 y+=16

 pygame.draw.line(s,C_SEP,(x,y),(x+W,y),1); y+=8

 # ── Move list ─────────────────────────────────────────────────────────

 hist=self.cb.history; ROW=17; max_vis=13

 pairs=[]

 i=0

 while i<len(hist):

 ws=hist[i]['move']['san']; i+=1

 bs=hist[i]['move']['san'] if i<len(hist) else ''; i+=1

 pairs.append((len(pairs)+1,ws,bs))

 start_p=max(0,len(pairs)-max_vis); ml_y=y

 for rel,(mn,ws,bs) in enumerate(pairs[start_p:]):

 ry=ml_y+rel*ROW

 tl(s,f"{mn}.",FNT_MONO_SM,C_TEXT3,x,ry)

 tl(s,ws, FNT_MONO_SM,(225,220,205),x+30,ry)

 if bs: tl(s,bs,FNT_MONO_SM,(205,205,220),x+118,ry)

 y=ml_y+max_vis*ROW+4

 pygame.draw.line(s,C_SEP,(x,y),(x+W,y),1); y+=8

 if self.cb.is_check() and not self.cb.result:

 tc(s,"Check!",FNT_SMB,C_RED,px0+PW//2,y+8); y+=22

 if self.ai_busy:

 sf=isinstance(self.ai,StockfishAI) and self.ai.ready

 lbl2="Stockfish thinking..." if sf else "AI thinking..."

 tc(s,lbl2,FNT_SM,C_GOLD,px0+PW//2,y+8); y+=20

 if self.notif and time.time()<self.notif_exp:

 tc(s,self.notif,FNT_SM,C_ACCENT,px0+PW//2,y+8)

 # ── BOTTOM captured pieces ────────────────────────────────────────────

 bot_caps_disp = b_cap if bot_color==BLACK else w_cap # symmetric

 bot_adv_pts = max(0, -adv) if bot_color==BLACK else max(0, adv)

 bot_cap_y = 488

 if bot_caps_disp:

 self._draw_caps(s, bot_caps_disp, bot_adv_pts, x, bot_cap_y, W)

 # ── BOTTOM player box ─────────────────────────────────────────────────

 bot_act = (self.cb.turn==bot_color and self.clk_run and not self.cb.result)

 rr(s,(52,50,44) if bot_act else C_PANEL2,(x,506,W,44),6,1,C_BORDER)

 tc(s,bot_label,FNT_XS,C_TEXT2,x+W//2,506+10)

 tc(s,fmt(bot_time),FNT_CLK,C_GOLD if bot_act else C_TEXT,x+W//2,506+30)

 # ── Buttons ───────────────────────────────────────────────────────────

 for lbl2,bx,by,bw2,bh,c in[("Review",10,558,84,30,C_GOLD),

 ("Rematch",104,558,80,30,C_BTN),

 ("Menu",194,558,84,30,C_BTN)]:

 ax=px0+bx; hov=(ax<=mx<=ax+bw2 and by<=my<=by+bh)

 rr(s,C_BTN_H if hov else c,(ax,by,bw2,bh),5,1,C_BORDER)

 tc(s,lbl2,FNT_SM,C_TEXT,ax+bw2//2,by+bh//2)

 tc(s,"F:flip R:review ESC:menu",FNT_XS,C_TEXT3,px0+PW//2,WINDOW_H-10)

 def _d_promo(self):

 s=self.surf; cx,cy=BOARD_SIZE//2,BOARD_SIZE//2; mx,my=pygame.mouse.get_pos()

 ov=pygame.Surface((BOARD_SIZE,BOARD_SIZE),pygame.SRCALPHA); ov.fill((0,0,0,168)); s.blit(ov,(0,0))

 rr(s,(46,44,38),(cx-125,cy-60,250,118),12,1,C_BORDER)

 tc(s,"Promote to:",FNT_MD,C_TEXT2,cx,cy-42)

 for i,p in enumerate(['Q','R','B','N']):

 piece=p if self.cb.turn==WHITE else p.lower()

 bx=cx-105+i*54; by=cy-22; hov=(bx<=mx<=bx+52 and by<=my<=by+50)

 rr(s,C_BTN_H if hov else C_BTN,(bx,by,52,50),7,1,C_BORDER)

 draw_piece_at(s,piece,bx,by,50)

 # ── Review ────────────────────────────────────────────────────────────────

 # Layout in review mode:

 # x=0..18 : eval bar (white=bottom, black=top)

 # x=18..658 : chess board (640px) → board offset = 18

 # x=658..958: panel (300px)

 EVAL_BAR_W = 18

 BOARD_OX = 18 # board x-offset in review mode

 def _d_rev(self):

 s=self.surf; idx=self.rev_idx

 board=self.rev_boards[idx] if idx<len(self.rev_boards) else (self.rev_boards[-1] if self.rev_boards else None)

 lm=None

 if idx>0: m=self.rev_hist[idx-1]['move']; lm=(m['from'],m['to'])

 # ── Eval bar (left strip) ─────────────────────────────────────────────

 self._d_eval_bar(s)

 # ── Board (shifted right by EVAL_BAR_W) ──────────────────────────────

 # Draw squares

 fl=self.flipped

 chk_sq=None # no check highlight in review

 for r in range(8):

 for c in range(8):

 px=self.BOARD_OX+((7-c)*SQ if fl else c*SQ)

 py=(7-r)*SQ if fl else r*SQ

 base=C_LIGHT_SQ if(r+c)%2==0 else C_DARK_SQ

 pygame.draw.rect(s,base,(px,py,SQ,SQ))

 if lm and ((r,c)==lm[0] or (r,c)==lm[1]):

 hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_HIGHLIGHT,170)); s.blit(hl,(px,py))

 # Classification highlight on destination square

 if idx>0:

 clf=self.rev_analyser.get_classif(idx) if self.rev_analyser else 'none'

 clf_clr,_,_=CLF_INFO.get(clf,CLF_INFO['none'])

 tr2,tc3=lm[1]

 px2=self.BOARD_OX+((7-tc3)*SQ if fl else tc3*SQ)

 py2=(7-tr2)*SQ if fl else tr2*SQ

 hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*clf_clr,90)); s.blit(hl,(px2,py2))

 # Badge circle on corner

 bx3=px2+(SQ-18 if not fl else 0); by3=py2

 pygame.draw.circle(s,clf_clr,(bx3+9,by3+9),9)

 sym=CLF_INFO.get(clf,('','',''))[1]

 if sym:

 f2,_=_pfont(10); st=f2.render(sym,True,(255,255,255))

 s.blit(st,(bx3+9-st.get_width()//2,by3+9-st.get_height()//2))

 # Coordinates

 for i in range(8):

 ci=7-i if fl else i; ri=i if fl else 7-i

 lc=C_DARK_SQ if(7+i)%2==0 else C_LIGHT_SQ

 lr=C_DARK_SQ if i%2==1 else C_LIGHT_SQ

 lt=FNT_XS.render('abcdefgh'[ci],True,lc)

 s.blit(lt,(self.BOARD_OX+i*SQ+SQ-lt.get_width()-3,BOARD_SIZE-lt.get_height()-2))

 ln=FNT_XS.render(str(ri+1),True,lr)

 s.blit(ln,(self.BOARD_OX+3,i*SQ+2))

 # Pieces

 if board:

 for r in range(8):

 for c in range(8):

 p=board[r][c]

 if not p: continue

 px=self.BOARD_OX+((7-c)*SQ if fl else c*SQ)

 py=(7-r)*SQ if fl else r*SQ

 draw_piece_at(s,p,px,py)

 self._d_rev_nav(s)

 self._d_rev_panel(s)

 def _d_eval_bar(self,s):

 """Draw a vertical eval bar on the left edge."""

 ra=self.rev_analyser; idx=self.rev_idx

 ev=None

 if ra: ev=ra.get_eval(idx)

 BW=self.EVAL_BAR_W; H=BOARD_SIZE

 # Background

 pygame.draw.rect(s,(30,28,24),(0,0,BW,H))

 # Convert eval to 0..1 (white fraction of bar, measured from BOTTOM)

 if ev is None:

 wfrac=0.5

 else:

 # clamp to ±1000cp, sigmoid-ish

 clamped=max(-1000,min(1000,ev))

 wfrac=0.5+clamped/2000.0

 wfrac=max(0.05,min(0.95,wfrac))

 black_h=int(H*(1-wfrac)); white_h=H-black_h

 # Black portion (top)

 pygame.draw.rect(s,(30,30,30),(0,0,BW,black_h))

 # White portion (bottom)

 pygame.draw.rect(s,(220,215,200),(0,black_h,BW,white_h))

 # Midline

 pygame.draw.line(s,(80,78,72),(0,H//2),(BW,H//2),1)

 # Score text

 if ev is not None:

 if abs(ev)>=29000: txt="M" if ev>0 else "-M"

 else: txt=f"{ev/100:+.1f}" if abs(ev)<1000 else f"{ev/100:+.0f}"

 fs=10; f2,_=_pfont(fs)

 st=FNT_XS.render(txt,True,(200,200,200) if abs(ev)<50 else (C_ACCENT if ev>0 else C_RED))

 # rotate 90° for vertical text

 st_r=pygame.transform.rotate(st,90)

 s.blit(st_r,(BW//2-st_r.get_width()//2, H//2-st_r.get_height()//2))

 # "Analysing..." if not done

 if ra and not ra.done:

 dot_y=H-24

 dt=FNT_XS.render("…",True,C_GOLD)

 s.blit(dt,(BW//2-dt.get_width()//2,dot_y))

 def _d_rev_nav(self,s):

 ox=self.BOARD_OX; cx=ox+BOARD_SIZE//2; by0=BOARD_SIZE-46; mx,my=pygame.mouse.get_pos()

 for bx,by,bw,bh,lbl in[(cx-132,by0,40,36,'|<'),(cx-84,by0,40,36,'<'),

 (cx+44, by0,40,36,'>'),(cx+92,by0,40,36,'|>')]:

 hov=(bx<=mx<=bx+bw and by<=my<=by+bh)

 rr(s,C_BTN_H if hov else C_PANEL3,(bx,by,bw,bh),6,1,C_BORDER)

 tc(s,lbl,FNT_MDB,C_TEXT,bx+bw//2,by+bh//2)

 tc(s,f"{self.rev_idx} / {len(self.rev_hist)}",FNT_SM,C_TEXT2,cx,by0+18)

 tc(s,"Arrow keys or buttons to navigate",FNT_XS,C_TEXT3,cx,BOARD_SIZE-7)

 def _d_rev_panel(self,s):

 # Panel starts at BOARD_OX + BOARD_SIZE

 px0=self.BOARD_OX+BOARD_SIZE; PW=PANEL_WIDTH-(self.BOARD_OX); x=px0+14

 mx,my=pygame.mouse.get_pos()

 pygame.draw.rect(s,C_PANEL,(px0,0,WINDOW_W-px0,WINDOW_H))

 pygame.draw.line(s,C_BORDER,(px0,0),(px0,WINDOW_H),1)

 W=WINDOW_W-px0-28; y=14

 tc(s,"Game Review",FNT_LG,C_GOLD,px0+(WINDOW_W-px0)//2,y+10); y+=34

 # ── Analysis status / current move feedback ───────────────────────────

 ra=self.rev_analyser; idx=self.rev_idx

 if idx>0 and ra:

 clf=ra.get_classif(idx)

 clf_clr,sym,desc=CLF_INFO.get(clf,CLF_INFO['none'])

 if clf!='none':

 rr(s,(*clf_clr,40),(x,y,W,38),6) # won't work with alpha directly

 pygame.draw.rect(s,(clf_clr[0]//4,clf_clr[1]//4,clf_clr[2]//4),(x,y,W,38),border_radius=6)

 pygame.draw.rect(s,clf_clr,(x,y,W,38),1,border_radius=6)

 tc(s,f"{sym} {clf.upper()}",FNT_SMB,clf_clr,x+W//2,y+12)

 tc(s,desc.split('—')[1].strip() if '—' in desc else desc,FNT_XS,C_TEXT2,x+W//2,y+27)

 y+=46

 elif ra and not ra.done:

 tc(s,"Analysing game...",FNT_SM,C_GOLD,px0+(WINDOW_W-px0)//2,y+10); y+=24

 else:

 y+=4

 pygame.draw.line(s,C_SEP,(x,y),(x+W,y),1); y+=8

 # ── Paired move list with classification badges ────────────────────────

 hist=self.rev_hist; cur=self.rev_idx; ROW=18; max_vis=16

 pairs=[]

 i=0

 while i<len(hist):

 ws=hist[i]['move']['san']; wclf=ra.get_classif(i+1) if ra else 'none'; i+=1

 bs=hist[i]['move']['san'] if i<len(hist) else ''

 bclf=ra.get_classif(i+1) if (ra and i<len(hist)) else 'none'; i+=1

 pairs.append((len(pairs)+1, ws,wclf, bs,bclf))

 cur_row=(cur-1)//2 if cur>0 else 0

 start=max(0,cur_row-max_vis//2); end=min(len(pairs),start+max_vis); start=max(0,end-max_vis)

 COL_NUM=x; COL_W=x+28; COL_B=x+W//2+4

 for rel,(mn,ws,wclf,bs,bclf) in enumerate(pairs[start:end]):

 pi=start+rel; ry=y+rel*ROW

 w_act=(cur==pi*2+1); b_act=(cur==pi*2+2)

 if w_act: pygame.draw.rect(s,(68,65,48),(px0+4,ry-1,W//2,ROW),border_radius=3)

 if b_act: pygame.draw.rect(s,(68,65,48),(px0+4+W//2,ry-1,W//2,ROW),border_radius=3)

 tl(s,f"{mn}.",FNT_MONO_SM,C_TEXT3,COL_NUM,ry)

 # White move + badge

 wclr_b,wsym,_=CLF_INFO.get(wclf,CLF_INFO['none'])

 wclr=C_GOLD if w_act else (wclr_b if wclf not in('none','best') else (225,220,205))

 tl(s,ws,FNT_MONO_SM,wclr,COL_W,ry)

 if wsym and wclf not in('none',):

 bt=FNT_XS.render(wsym,True,wclr_b)

 s.blit(bt,(COL_W+40,ry+1))

 # Black move + badge

 if bs:

 bclr_b,bsym,_=CLF_INFO.get(bclf,CLF_INFO['none'])

 bclr=C_GOLD if b_act else (bclr_b if bclf not in('none','best') else (205,205,220))

 tl(s,bs,FNT_MONO_SM,bclr,COL_B,ry)

 if bsym and bclf not in('none',):

 bt2=FNT_XS.render(bsym,True,bclr_b)

 s.blit(bt2,(COL_B+40,ry+1))

 bx,by2,bw,bh=px0+6,558,WINDOW_W-px0-12,30

 hov=(bx<=mx<=bx+bw and by2<=my<=by2+bh)

 rr(s,C_BTN_H if hov else C_BTN,(bx,by2,bw,bh),5,1,C_BORDER)

 back="< Back to Game" if self.rev_src==S.PLAYING else "< Back to Records"

 tc(s,back,FNT_SM,C_TEXT,px0+(WINDOW_W-px0)//2,by2+bh//2)

 # ── History screen ────────────────────────────────────────────────────────

 def _d_hist(self):

 s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()

 tc(s,"Game Records",FNT_LG,C_TEXT,cx,45)

 pygame.draw.line(s,C_BORDER,(40,70),(WINDOW_W-40,70),1)

 recs=self.hist_recs

 if not recs: tc(s,"No games recorded yet.",FNT_MD,C_TEXT2,cx,WINDOW_H//2)

 else:

 item_h=70; list_y0=88; vis=min(7,(WINDOW_H-170)//item_h)

 for rel in range(vis):

 idx=rel+self.hist_scroll

 if idx>=len(recs): break

 rec=recs[-(idx+1)]; ry=list_y0+rel*item_h

 hov=(30<=mx<=WINDOW_W-30 and ry<=my<=ry+item_h-3)

 bg=C_PANEL3 if hov else(C_PANEL2 if rel%2==0 else C_PANEL)

 rr(s,bg,(30,ry,WINDOW_W-60,item_h-4),6,1,C_BORDER)

 res=rec.get('result','?')

 rclr=C_ACCENT if'White' in res else(C_RED if'Black' in res else C_TEXT2)

 tl(s,res,FNT_MDB,rclr,50,ry+6)

 reason=rec.get('reason','').capitalize()

 if reason: tl(s,f"by {reason}",FNT_XS,C_TEXT2,50,ry+24)

 mode='vs Bot' if rec.get('mode')=='bot' else'Local 2P'

 lvl=f" · Lv{rec.get('ai_level','?')}" if rec.get('mode')=='bot' else''

 tl(s,f"{mode}{lvl} · {rec.get('tc','?')}",FNT_SM,C_TEXT,230,ry+8)

 tl(s,f"{rec.get('total_moves','?')} moves",FNT_XS,C_TEXT2,230,ry+26)

 tl(s,rec.get('date',''),FNT_XS,C_TEXT3,WINDOW_W-215,ry+8)

 moves=rec.get('moves',[]); prev=' '.join(f"{(i//2)+1}.{m}" if i%2==0 else m for i,m in enumerate(moves[:10]))

 if len(moves)>10: prev+='...'

 tl(s,prev,FNT_XS,C_TEXT3,50,ry+44)

 if hov: tc(s,"Click to review",FNT_XS,C_GOLD,WINDOW_W-90,ry+item_h//2-4)

 total=len(recs)

 if total>vis:

 area=WINDOW_H-170; sbh=max(20,int(vis/total*area))

 sby=list_y0+int(self.hist_scroll/max(1,total-vis)*(area-sbh))

 pygame.draw.rect(s,C_PANEL3,(WINDOW_W-14,list_y0,6,area))

 pygame.draw.rect(s,C_TEXT2,(WINDOW_W-14,sby,6,sbh),border_radius=3)

 bbx,bby,bbw,bbh=cx-80,WINDOW_H-48,160,34; hov=(bbx<=mx<=bbx+bbw and bby<=my<=bby+bbh)

 rr(s,C_BTN_H if hov else C_BTN,(bbx,bby,bbw,bbh),6,1,C_BORDER)

 tc(s,"< Main Menu",FNT_SM,C_TEXT,cx,bby+bbh//2)

 # FIX #5 – confirm overlay

 def _d_hconf(self):

 s=self.surf; cx,cy=WINDOW_W//2,WINDOW_H//2; mx,my=pygame.mouse.get_pos()

 ov=pygame.Surface((WINDOW_W,WINDOW_H),pygame.SRCALPHA); ov.fill((0,0,0,155)); s.blit(ov,(0,0))

 rr(s,(46,44,38),(cx-185,cy-80,370,170),12,1,C_BORDER)

 tc(s,"Review this game?",FNT_LG,C_TEXT,cx,cy-52)

 if self.hist_confirm is not None and self.hist_recs:

 rec=self.hist_recs[-(self.hist_confirm+1)]

 info=f"{rec.get('result','?')} · {rec.get('total_moves','?')} moves · {rec.get('date','')}"

 tc(s,info,FNT_SM,C_TEXT2,cx,cy-22)

 for lbl2,bx,c in[("Review >",cx-120,C_BLUE),("Cancel",cx+10,C_BTN)]:

 bw2,bh2=110,46; by=cy+18; hov=(bx<=mx<=bx+bw2 and by<=my<=by+bh2)

 rr(s,tuple(min(255,v+20) for v in c) if hov else c,(bx,by,bw2,bh2),8,1,C_BORDER)

 tc(s,lbl2,FNT_MDB,C_TEXT,bx+bw2//2,by+bh2//2)

# ═══════════════════════════════════════════════════════════════════════════════

if __name__ == '__main__':

 Game().run() 

  