Skip to main content

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()