Novag Citrine Interface with GUI

Here is the code:

import serial, time, chess, chess.uci
import tkinter as tk

class ChessBoard(chess.Board):
    ''' This class is chess.Board with the addition of a Standard Algebraic
    Notation (SAN) stack, and some additional methods.
    '''
    def __init__(self, fen=
        'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
        chess960=False):
        ''' Initialize chess.Board and the SAN move stack.
        '''
        chess.Board.__init__(self, fen, chess960)
        self.san_stack = []

    def pushem(self, uci):
        ''' Push a UCI formatted move onto chess.Board, and onto the SAN stack.
        '''
        move = chess.Board.parse_uci(self, uci)
        san = chess.Board.san(self, move)
        self.san_stack.append(san)
        chess.Board.push(self, move)

    def popem(self):
        ''' Pop the last move from chess.Board, and from the SAN stack.
        '''
        chess.Board.pop(self)
        return self.san_stack.pop()

    def pgn(self):
        ''' Return the PGN for the game.
        '''
        n, out = 1, []
        for m in self.san_stack:
            n += 1
            out.append(((str(n//2) + '.') if n%2 == 0 else '') + str(m))
        return ' '.join(out)

    def unicode(self):
        ''' Return a Unicode version of the simple Ascii board.
        '''
        uni_sym = {' ': ' ', '\n': '\n', '.': u'\u00B7',
            'R': u'♖', 'r': u'♜', 'N': u'♘', 'n': u'♞',
            'B': u'♗', 'b': u'♝', 'Q': u'♕', 'q': u'♛',
            'K': u'♔', 'k': u'♚', 'P': u'♙', 'p': u'♟'}
        return ''.join([uni_sym[char] for char in str(self)])

    def last_san_move(self):
        ''' Return a SAN representation of the last move played, or "New Game",
        if it is a new game.
        '''
        _half_moves = len(self.san_stack)
        if _half_moves == 0:
            return 'New Game'
        else:
            _dots =  '.' if _half_moves % 2 == 1 else '...'
            return str((_half_moves+1)//2) + _dots + self.san_stack[-1]
     
ENGINE_OPTIONS = ['PC Engine', 'Novag BE8']
for level in range(1,9):
    ENGINE_OPTIONS.append('Novag AT' + str(level))
     
class GUI(tk.Tk):
    ''' Graphical User Interface (GUI).
    '''
    def __init__(self):
        ''' Create and initialize a new GUI object.
        '''
        tk.Tk.__init__(self)
        self.title('Novag Citrine Interface')
        top_frame = tk.Frame(self)
        top_frame.pack(pady=5)
        self.start_stop_button = tk.Button(top_frame, text='Start Engine',
                    bg='light green', command=self._start_stop_button_pressed)
        self.start_stop_button.pack(side='left')
        self.engine_option = tk.StringVar()
        self.engine_option.set(ENGINE_OPTIONS[0])
        self.engine_option.trace('w', self._engine_option_changed)
        option_menu = tk.OptionMenu(top_frame, self.engine_option,
                                    *ENGINE_OPTIONS)
        option_menu.pack(side='right')
        self.position_label = tk.Label(self, text=board.unicode(),
                                   font=('Dejavu Sans Mono', 30), padx=20)
        self.position_label.pack()
        self.move_label = tk.Label(self, text='New Game',
                                   font=('Dejavu Sans Mono', 12), pady=5)
        self.move_label.pack()
        self.engine_on = self.engine_move_now = False

    def _start_stop_button_pressed(self):
        ''' Respond to an Engine Start/Stop button press.
        '''
        if self.engine_on:
            self.engine_on = False
            self.start_stop_button['text'] = 'Start Engine'
            self.start_stop_button['bg'] = 'light green'
        else:
            self.engine_on = self.engine_move_now = True
            self.start_stop_button['text'] = 'Stop Engine'
            self.start_stop_button['bg'] = 'pink'

    def _engine_option_changed(self, *args):
        ''' Respond to a change in the engine option by changing the Citrine
        playing level.
        '''
        option = self.engine_option.get()
        if option[:6] == 'Novag ':
            send_command('l ' + option[6:].lower())
        else:
            send_command('l tr8')

    def update_posn(self):
        ''' Update the position and last move.
        '''
        self.position_label['text'] = board.unicode()
        self.move_label['text'] = board.last_san_move()
        self.update()

    def new_game(self):
        ''' User has reset the pieces on the Novag to start a new game.
        '''
        self.engine_on = False
        self.start_stop_button['text'] = 'Start Engine'
        self.start_stop_button['bg'] = 'light green'
        self.update_posn()

def parse_novag(novag_move):
    ''' Split a Novag move or take-back response into four fields: M or T,
    move number, move colour, and the move converted to UCI format.
    '''
    m_or_t, mn_c, mv = novag_move.split()
    mn = int(mn_c.strip(',')) # Move number.
    mc = mn_c[-1] != ',' # True for a white move.
    if mv == 'O-O':
        if mc:
            return (m_or_t, mn, mc, 'e1g1')
        else:
            return (m_or_t, mn, mc, 'e8g8')
    elif mv == 'O-O-O':
        if mc:
            return (m_or_t, mn, mc, 'e1c1')
        else:
            return (m_or_t, mn, mc, 'e8c8')
    elif len(mv) == 5 or mv[5:7] == 'ep':
        return (m_or_t, mn, mc, mv[0:2] + mv[3:5])
    else:
        return (m_or_t, mn, mc, mv[0:2] + mv[3:5] + mv[6].lower())

def novag_move(uci_move, board):
    ''' Convert a UCI move to a lowercase Novag format move, in the context
    of the board position.
    '''
    if uci_move == 'e1g1':
        return 'O-O' if board.piece_at(chess.E1) == 'K' else uci_move
    elif uci_move == 'e1c1':
        return 'O-O-O' if board.piece_at(chess.E1) == 'K' else uci_move
    elif uci_move == 'e8g8':
        return 'O-O' if board.piece_at(chess.E8) == 'k' else uci_move
    elif uci_move =='e8c8':
        return 'O-O-O'if board.piece_at(chess.E8) == 'k' else uci_move
    elif len(uci_move) == 5:
        return uci_move[:4] + '/' + uci_move[4]
    else:
        return uci_move

def send_command(command):
    ''' Send a command from the PC to the Citrine.
    '''
    ser.write((command + '\r\n').encode(encoding='UTF-8'))
    time.sleep(0.1)

def new_game():
    ''' Initialize a new game.
    '''
    global board    
    try:
        print(board.pgn())
    except:
        pass
    board = ChessBoard()
    send_command('n') # New Game.
    send_command('u on') # Turn Referee Mode on.
    send_command('x on') # Turn Xmit on.
    send_command('l tr8') # Set the Level.

    while True:
        line = ser.readline().decode(encoding='UTF-8')[:-1]
        if line == '':
            break
        print(line)

def engine_move():
    ''' Calculate a move using the onboard Citrine engine, or calculate a move
    with the PC engine and send it to the Citrine.
    '''
    global engine_turn
    if gui.engine_option.get()[:6] == 'Novag ':
        print('Novag Engine Move')
        send_command('j')
        gui.update_posn()
        engine_turn = False
    elif board.is_game_over(): # PC engine move and game over.
        engine_turn = True
    else:
        engine.position(board) # PC engine move and game ongoing
        uci_move = engine.go(movetime=100).bestmove.uci()
        nv_move = novag_move(uci_move, board)
        print('PC Engine Move:', nv_move)
        if ser.inWaiting() == 0: # Check that there are no take-backs.
            send_command('m' + nv_move)
            if board.is_capture(board.parse_uci(uci_move)):
                time.sleep(3.0)
            send_command('m' + nv_move)
            engine_turn = False
        else:
            engine_turn = True

GAME_RESULT = ('Draw by repetion', 'Draw by 50 move rule',
               'Draw by insufficient material', 'Stalemate',
               'Checkmate', 'Citrine resigns')

def respond_to_novag():
    ''' Respond to replies from the Citrine, which should be moves,
    optionally followed by take backs. If the reply begins with 'M' it
    is a move, and if it begins with 'T' it is a take-back. 'New Game'
    indicates that the user has reset the pieces to start a new game.
    The final line of this function returns to the Tkinter main loop and
    requests a call back after 100 mS.
    '''
    global engine_turn # False if the echoed move is the engine's move.
    line = ser.readline().decode(encoding='UTF-8')[:-1]
    if line != '':
        print(line)
        if line[0] == 'M':
            if line[1] == '#': # Game over echoed.
                print(GAME_RESULT[int(line[2])-1])
            else: # Move echoed.
                mt, mn, mc, mv = parse_novag(line)
                if mn != board.fullmove_number or mc != board.turn:
                    raise ValueError
                board.pushem(mv)
                gui.update_posn()
                if gui.engine_on and engine_turn:
                    engine_move()
                else:
                    engine_turn = True
        elif line[0] == 'T': # Take-back echoed.
            mt, mn, mc, mv = parse_novag(line)
            board.popem()
            if mn != board.fullmove_number or mc != board.turn:
                raise ValueError
            gui.update_posn()
        elif line[:8] == 'New Game':
            new_game() # User has reset the start position.
            gui.new_game()
    elif gui.engine_move_now:
        gui.engine_move_now = False
        engine_move()
    gui.after(100, respond_to_novag)

def print_engine_options(eo):
    ''' Print engine options.
    '''
    print('Engine Option         Type    Default Min    Max     Var')
    for k in sorted(eo):
        show_empty = lambda x : x if x != '' else "''"
        option = str(k).ljust(22) + str(eo[k].type).ljust(8) + \
            show_empty(str(eo[k].default)).ljust(8) + \
            str(eo[k].min).ljust(7) + str(eo[k].max).ljust(8) + \
            ' '.join([str(v) for v in eo[k].var])
        print(option)

port = '/dev/ttyUSB0'
ser = serial.Serial(port, 57600, timeout=0.05)
print(ser.name, 'opened')
engine = chess.uci.popen_engine('/home/geoff/scid/stockfish 7 x64 bmi2')
engine.uci()
print(engine.name)
print_engine_options(engine.options)
engine.setoption({'Skill Level' : 7})
new_game()
gui = GUI()
respond_to_novag()
gui.mainloop()
if board.pgn() != '':
    print(board.pgn())
engine.quit()
ser.close()

No comments:

Post a Comment