Updated Novag Citrine Interface

import serial, time, chess, chess.engine, chess.pgn
from tkinter import *
from tkinter import messagebox
from tkinter import simpledialog
from datetime import date

ENGINES = [('Stockfish', '/usr/games/stockfish', {'Skill Level': 7}),
           ('Ethereal', '/usr/games/ethereal-chess', {}),
           ('Fruit', '/usr/games/fruit', {}),
           ('Toga II', '/usr/games/toga2', {}),
           ('Gnuchess', '/usr/games/gnuchessu', {})]

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.
    '''
    move_str = board.uci(uci_move)
    if move_str == 'e1g1':
        pe1 = board.piece_at(chess.E1)
        return 'O-O' if pe1.symbol() == 'K' else move_str
    elif move_str == 'e1c1':
        pe1 = board.piece_at(chess.E1)
        return 'O-O-O' if pe1.symbol() == 'K' else move_str
    elif move_str == 'e8g8':
        pe8 = board.piece_at(chess.E8)
        return 'O-O' if pe8.symbol() == 'k' else move_str
    elif move_str =='e8c8':
        pe8 = board.piece_at(chess.E8)
        return 'O-O-O'if pe8.symbol() == 'k' else move_str
    elif len(move_str) == 5:
        return move_str[:4] + '/' + move_str[4]
    else:
        return move_str

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

def save_game():
    ''' Ask if the user wants to save the game. If so, write the PGN for the
    game to Chessgames.txt, or append it to the file if it already exists.
    '''
    if messagebox.askyesno('Save Game', 'Do you want to save the game?'):
        header = engine_option.get() + ' ' + date.today().strftime('%d/%m/%Y')
        game = str(chess.pgn.Game().from_board(board)).split('\n')
        game = '\n'.join(game[8:]) # Strip empty PGN header.
        print(header + '\n' + game)
        f = open('ChessGames.txt', 'a')
        f.write(header + '\n' + game + '\n\n')
        f.close()

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

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

GAME_RESULT = ('Draw by repetion', 'Draw by 50 move rule',
               'Draw by insufficient material', 'Stalemate',
               'Checkmate', 'Computer 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.
    global engine_move_now # Start Engine button has just been pressed.
    global board
    line = ser.readline().decode(encoding='UTF-8')[:-2]
    if line != '':
        print('Novag>',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)
                uci_move = chess.Move.from_uci(mv)
                san_move = board.variation_san([uci_move])
                if mn != board.fullmove_number or mc != board.turn:
                    raise ValueError
                board.push(uci_move)
                update_posn(san_move)
                if 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)
            uci_move = board.pop()
            if mn != board.fullmove_number or mc != board.turn:
                raise ValueError
            uci_move = board.pop()
            san_move = board.variation_san([uci_move])
            board.push(uci_move)
            update_posn(san_move)
        elif line[:8] == 'New Game':
            new_game() # User has reset the start position.
            save_game()
            gui_new_game()
    elif engine_move_now:
        engine_move_now = False
        engine_move()
    root.after(100, respond_to_novag)

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 Novag_engine, board, ser, engine_turn
    if Novag_engine:
        print('Novag Engine Move')
        send_command('j')
        engine_turn = False
    elif board.is_game_over(): # PC engine move and game over.
        engine_turn = True
    else:
        # PC engine move and game ongoing
        try:
            uci_move = engine.play(board, chess.engine.Limit(time=1.0)).move
            nv_move = novag_move(uci_move, board)
            print('Pi Engine Move:', nv_move)
            if ser.inWaiting() == 0: # Check that there are no take-backs.
                send_command('m' + nv_move)
                if board.is_capture(uci_move):
                    time.sleep(3.0)
                send_command('m' + nv_move)
                engine_turn = False
            else:
                engine_turn = True
        except:
            print('Pi Engine failed to respond')

def print_engine_options(eo):
    ''' Print engine options.
    '''
    print('Engine Option         Type    Default               Min    Max     Var')
    for k in 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(22) + \
            str(eo[k].min).ljust(7) + str(eo[k].max).ljust(8) + \
            ' '.join([str(v) for v in eo[k].var])
        print(option)

def unicode_board(ascii):
    ''' Return a Chess Alpha font Unicode version of the simple Ascii board.
    '''
    lightsq_unicode = {'\n': '\n', '.': u'\u0020',
        'R': u'\u0072', 'r': u'\u0074', 'N': u'\u0068', 'n': u'\u006A',
        'B': u'\u0062', 'b': u'\u006E', 'Q': u'\u0071', 'q': u'\u0077',
        'K': u'\u0008', 'k': u'\u006C', 'P': u'\u0070', 'p': u'\u006F'}
    darksq_unicode = {'\n': '\n', '.': u'\u002B',
        'R': u'\u0052', 'r': u'\u0054', 'N': u'\u0048', 'n': u'\u004A',
        'B': u'\u0042', 'b': u'\u004E', 'Q': u'\u0051', 'q': u'\u0057',
        'K': u'\u004B', 'k': u'\u004C', 'P': u'\u0050', 'p': u'\u004F'}   
    unicode = ''
    sq_count = 0
    for ch in str(ascii):
        if ch != ' ':
            if sq_count % 2 == 0:
                unicode += lightsq_unicode[ch]
            else:
                unicode += darksq_unicode[ch]
            sq_count += 1
    return unicode
     
ENGINE_OPTIONS = ['-- Select an Engine --']
for e in ENGINES:
    ENGINE_OPTIONS.append(e[0])
ENGINE_OPTIONS.append('Novag BE8')
for level in range(1,9):
    ENGINE_OPTIONS.append('Novag AT' + str(level))

def engine_option_changed(*args):
    ''' Respond to a change in the engine option by changing the Citrine
    playing level.
    '''
    global ENGINES, ENGINE_OPTIONS, Novag_engine, engine, engine_name
    option = engine_option.get()
    option_index = ENGINE_OPTIONS.index(option)
    if option_index == 0: return
    option_menu.config(bg='light gray')
    Novag_engine = option_index > len(ENGINES)
    if Novag_engine:
        send_command('l ' + option[6:].lower())
    else:
        send_command('l ea1')
        engine_name, engine_path, selected_options = ENGINES[option_index - 1]
        try:
            engine = chess.engine.SimpleEngine.popen_uci(engine_path)
            engine.configure(selected_options)
            print(engine_name)
            print_engine_options(engine.options)
        except:
            print('Pi engine failed to connect')

def start_stop_button_pressed():
    ''' Respond to an Engine Start/Stop button press.
    '''
    global ENGINE_OPTIONS, engine_on, engine_move_now
    option = engine_option.get()
    option_index = ENGINE_OPTIONS.index(option)
    if option_index == 0:
        return
    if engine_on:
        engine_on = False
        start_stop_button['text'] = 'Start Engine'
        start_stop_button['bg'] = 'light green'
    else:
        engine_on = engine_move_now = True
        start_stop_button['text'] = 'Stop Engine'
        start_stop_button['bg'] = 'pink'

def command_button_pressed():
    ''' Send a user command to the Citrine.
    '''
    answer = simpledialog.askstring('Citrine Command',
                         'Type a Citrine command:', parent=root)
    if answer: send_command(answer)

def update_posn(san_move):
    ''' Update the position and last move.
    '''
    position_label['text'] = unicode_board(board)
    move_label['text'] = san_move
    root.update()

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

def on_closing():
    save_game()
    root.destroy()

''' Initalise the serial port, engine and game.
'''
# port = '/dev/ttyUSB0' # With USB serial port adapter.
port = '/dev/ttyS0' # With intergrated serial port.
ser = serial.Serial(port, 57600, timeout=0.05)
print(ser.name, 'opened')
new_game()
engine_on = engine_move_now = False
engine_name = ''

''' Set up the Graphical User Interface.
'''
root = Tk()
root.title('Novag Citrine Interface')
top_frame = Frame(root)
top_frame.pack(pady=6)
engine_option = StringVar()
engine_option.set(ENGINE_OPTIONS[0])
engine_option.trace('w', engine_option_changed)
option_menu = OptionMenu(top_frame, engine_option,
                            *ENGINE_OPTIONS)
option_menu.config(font=('Dejavu Sans', 10), bg='pink')
menu = top_frame.nametowidget(option_menu.menuname)
menu.config(font=('Dejavu Sans', 10))
option_menu.pack(side='left')
start_stop_button = Button(top_frame, text='Start Engine',
          font=('Dejavu Sans', 10), bg='light green',
                command=start_stop_button_pressed)
start_stop_button.pack(side='left')
command_button = Button(top_frame, text = u'\u2192 \u25A1',
          font=('Dejavu Sans', 10),
                        command = command_button_pressed)
command_button.pack(side='left')
position_frame = Frame(root, bd=4, relief=GROOVE)
position_frame.pack()
position_label = Label(position_frame, text=unicode_board(board),
                           font=('Chess Alpha', 30))
position_label.pack()
move_label = Label(root, text='New Game',
                           font=('Dejavu Sans', 12), pady=6)
move_label.pack()

''' Main loop.
'''
root.protocol("WM_DELETE_WINDOW", on_closing)
respond_to_novag()
root.mainloop()

''' Close down.
'''
try:
    if engine_name: engine.quit()
finally:
    ser.close()

No comments:

Post a Comment