From 1b778b1b97a688f2079b8ab68c1778971d576cb1 Mon Sep 17 00:00:00 2001 From: Rudis Muiznieks Date: Sun, 24 Apr 2022 10:07:39 -0500 Subject: [PATCH] drawing chess board implemented --- __main__.py | 14 +- graphics.py | 12 ++ plugin/chess/__init__.py | 13 ++ plugin/chess/draw.py | 88 ++++++++ plugin/chess/sunfish.py | 451 +++++++++++++++++++++++++++++++++++++++ plugin/info/__init__.py | 6 +- 6 files changed, 575 insertions(+), 9 deletions(-) create mode 100644 plugin/chess/__init__.py create mode 100644 plugin/chess/draw.py create mode 100644 plugin/chess/sunfish.py diff --git a/__main__.py b/__main__.py index 1365981..d5756ba 100644 --- a/__main__.py +++ b/__main__.py @@ -13,11 +13,13 @@ menu_config = [ {"sub_menu": [ MenuItem("Chess", MenuType.PLUGIN, - {"plugin": "chess"}), + {"plugin": "chess", + "arg": None}), ]}), MenuItem("Information", MenuType.PLUGIN, - {"plugin": "info"}), + {"plugin": "info", + "arg": None}), MenuItem("Reboot", MenuType.EXIT_CMD, {"command": "sudo reboot"}), @@ -43,10 +45,14 @@ try: ic(item) if item.menu_type == MenuType.PLUGIN: try: + graphics.clear() + graphics.text("Loading...", 0, 0, 1) + graphics.show() plugin = import_module("plugin." + item.data["plugin"]) ic(plugin) - plugin.execute(cinput, graphics) - except: + plugin.execute(cinput, graphics, item.data["arg"]) + except Exception as e: + ic(e) graphics.clear() graphics.text("Plugin error!", 0, 0, 1) graphics.show() diff --git a/graphics.py b/graphics.py index e8a6788..bde4205 100644 --- a/graphics.py +++ b/graphics.py @@ -21,5 +21,17 @@ class Graphics: def text(self, text, x, y, c): self._display.text(text, x * self.CHAR_WIDTH, y * self.LINE_HEIGHT, c) + def pixel(self, x, y, c): + self._display.pixel(x, y, c) + + def line(self, x1, y1, x2, y2, c): + self._display.line(x1, y1, x2, y2, c) + + def rect(self, x1, y1, x2, y2, c): + self._display.rect(x1, y1, x2, y2, c) + + def fill_rect(self, x1, y1, x2, y2, c): + self._display.fill_rect(x1, y1, x2, y2, c) + def show(self): self._display.show() diff --git a/plugin/chess/__init__.py b/plugin/chess/__init__.py new file mode 100644 index 0000000..45fe79c --- /dev/null +++ b/plugin/chess/__init__.py @@ -0,0 +1,13 @@ +from cinput import ControlInput, Button +from graphics import Graphics +from .draw import Draw +from .sunfish import initial + +def execute(cinput: ControlInput, graphics: Graphics, _): + draw = Draw(graphics) + draw.draw(initial) + + while True: + key = cinput.get_one_shot(5) + if key == Button.BTN_A: + return diff --git a/plugin/chess/draw.py b/plugin/chess/draw.py new file mode 100644 index 0000000..c223ff5 --- /dev/null +++ b/plugin/chess/draw.py @@ -0,0 +1,88 @@ +from icecream import ic +from graphics import Graphics + +class Draw: + BOARD_SIZE = 64 + SQUARE_SIZE = 8 + + PIECES = { + "p": [ + " . . . . . . ", + " . . ■ ■ . . ", + " . ■ - - ■ . ", + " . ■ - - ■ . ", + " . . ■ ■ . . ", + " . . . . . . "], + "r": [ + " ■ ■ ■ ■ ■ ■ ", + " ■ - - - - ■ ", + " . ■ - - ■ . ", + " . ■ - - ■ . ", + " ■ - - - - ■ ", + " ■ ■ ■ ■ ■ ■ "], + "n": [ + " . . ■ ■ ■ . ", + " . ■ - - - ■ ", + " ■ - - - - ■ ", + " . ■ - - - ■ ", + " . . ■ - - ■ ", + " . . ■ ■ ■ ■ "], + "b": [ + " . . ■ ■ . . ", + " . ■ - - ■ . ", + " . ■ - - ■ . ", + " . ■ - - ■ . ", + " ■ - - - - ■ ", + " ■ ■ ■ ■ ■ ■ "], + "q": [ + " ■ . . . . ■ ", + " ■ ■ . . ■ ■ ", + " ■ - ■ ■ - ■ ", + " ■ - - - - ■ ", + " ■ - - - - ■ ", + " ■ ■ ■ ■ ■ ■ "], + + "k": [ + " . . ■ ■ . . ", + " ■ ■ - - ■ ■ ", + " ■ - - - - ■ ", + " ■ ■ - - ■ ■ ", + " . ■ - - ■ . ", + " . ■ ■ ■ ■ . "], + } + + def __init__(self, graphics: Graphics): + self._graphics = graphics + + def _draw_piece(self, piece: list[str], white: bool, sqx, sqy, sqc): + c = 0 if sqc == 1 else 1 + filled = (white and sqc == 0) or (not white and sqc == 1) + for row in range(len(piece)): + pixels = piece[row].replace(" ", "") + for col in range(len(pixels)): + x = (sqx * self.SQUARE_SIZE) + col + 1 + y = (sqy * self.SQUARE_SIZE) + row + 1 + pixel = pixels[col] + if pixel == '■': + self._graphics.pixel(x, y, c) + elif pixel =='-': + self._graphics.pixel(x, y, c if filled else sqc) + + + def draw(self, board: str): + nb = "".join(board.split()) + self._graphics.clear() + c = True + for row in range(8): + c = not c + for col in range(8): + if c: + x = col * self.SQUARE_SIZE + y = row * self.SQUARE_SIZE + self._graphics.fill_rect(x, y, self.SQUARE_SIZE, self.SQUARE_SIZE, 1) + p = nb[(row * 8) + col] + if p.lower() in self.PIECES: + self._draw_piece(self.PIECES[p.lower()], p.isupper(), col, row, c) + + c = not c + self._graphics.show() diff --git a/plugin/chess/sunfish.py b/plugin/chess/sunfish.py new file mode 100644 index 0000000..2418721 --- /dev/null +++ b/plugin/chess/sunfish.py @@ -0,0 +1,451 @@ +#!/usr/bin/env pypy +# -*- coding: utf-8 -*- + +from __future__ import print_function +import re, sys, time +from itertools import count +from collections import namedtuple + +############################################################################### +# Piece-Square tables. Tune these to change sunfish's behaviour +############################################################################### + +piece = { 'P': 100, 'N': 280, 'B': 320, 'R': 479, 'Q': 929, 'K': 60000 } +pst = { + 'P': ( 0, 0, 0, 0, 0, 0, 0, 0, + 78, 83, 86, 73, 102, 82, 85, 90, + 7, 29, 21, 44, 40, 31, 44, 7, + -17, 16, -2, 15, 14, 0, 15, -13, + -26, 3, 10, 9, 6, 1, 0, -23, + -22, 9, 5, -11, -10, -2, 3, -19, + -31, 8, -7, -37, -36, -14, 3, -31, + 0, 0, 0, 0, 0, 0, 0, 0), + 'N': ( -66, -53, -75, -75, -10, -55, -58, -70, + -3, -6, 100, -36, 4, 62, -4, -14, + 10, 67, 1, 74, 73, 27, 62, -2, + 24, 24, 45, 37, 33, 41, 25, 17, + -1, 5, 31, 21, 22, 35, 2, 0, + -18, 10, 13, 22, 18, 15, 11, -14, + -23, -15, 2, 0, 2, 0, -23, -20, + -74, -23, -26, -24, -19, -35, -22, -69), + 'B': ( -59, -78, -82, -76, -23,-107, -37, -50, + -11, 20, 35, -42, -39, 31, 2, -22, + -9, 39, -32, 41, 52, -10, 28, -14, + 25, 17, 20, 34, 26, 25, 15, 10, + 13, 10, 17, 23, 17, 16, 0, 7, + 14, 25, 24, 15, 8, 25, 20, 15, + 19, 20, 11, 6, 7, 6, 20, 16, + -7, 2, -15, -12, -14, -15, -10, -10), + 'R': ( 35, 29, 33, 4, 37, 33, 56, 50, + 55, 29, 56, 67, 55, 62, 34, 60, + 19, 35, 28, 33, 45, 27, 25, 15, + 0, 5, 16, 13, 18, -4, -9, -6, + -28, -35, -16, -21, -13, -29, -46, -30, + -42, -28, -42, -25, -25, -35, -26, -46, + -53, -38, -31, -26, -29, -43, -44, -53, + -30, -24, -18, 5, -2, -18, -31, -32), + 'Q': ( 6, 1, -8,-104, 69, 24, 88, 26, + 14, 32, 60, -10, 20, 76, 57, 24, + -2, 43, 32, 60, 72, 63, 43, 2, + 1, -16, 22, 17, 25, 20, -13, -6, + -14, -15, -2, -5, -1, -10, -20, -22, + -30, -6, -13, -11, -16, -11, -16, -27, + -36, -18, 0, -19, -15, -15, -21, -38, + -39, -30, -31, -13, -31, -36, -34, -42), + 'K': ( 4, 54, 47, -99, -99, 60, 83, -62, + -32, 10, 55, 56, 56, 55, 10, 3, + -62, 12, -57, 44, -67, 28, 37, -31, + -55, 50, 11, -4, -19, 13, 0, -49, + -55, -43, -52, -28, -51, -47, -8, -50, + -47, -42, -43, -79, -64, -32, -29, -32, + -4, 3, -14, -50, -57, -18, 13, 4, + 17, 30, -3, -14, 6, -1, 40, 18), +} +# Pad tables and join piece and pst dictionaries +for k, table in pst.items(): + padrow = lambda row: (0,) + tuple(x+piece[k] for x in row) + (0,) + pst[k] = sum((padrow(table[i*8:i*8+8]) for i in range(8)), ()) + pst[k] = (0,)*20 + pst[k] + (0,)*20 + +############################################################################### +# Global constants +############################################################################### + +# Our board is represented as a 120 character string. The padding allows for +# fast detection of moves that don't stay within the board. +A1, H1, A8, H8 = 91, 98, 21, 28 +initial = ( + ' \n' # 0 - 9 + ' \n' # 10 - 19 + ' rnbqkbnr\n' # 20 - 29 + ' pppppppp\n' # 30 - 39 + ' ........\n' # 40 - 49 + ' ........\n' # 50 - 59 + ' ........\n' # 60 - 69 + ' ........\n' # 70 - 79 + ' PPPPPPPP\n' # 80 - 89 + ' RNBQKBNR\n' # 90 - 99 + ' \n' # 100 -109 + ' \n' # 110 -119 +) + +# Lists of possible moves for each piece type. +N, E, S, W = -10, 1, 10, -1 +directions = { + 'P': (N, N+N, N+W, N+E), + 'N': (N+N+E, E+N+E, E+S+E, S+S+E, S+S+W, W+S+W, W+N+W, N+N+W), + 'B': (N+E, S+E, S+W, N+W), + 'R': (N, E, S, W), + 'Q': (N, E, S, W, N+E, S+E, S+W, N+W), + 'K': (N, E, S, W, N+E, S+E, S+W, N+W) +} + +# Mate value must be greater than 8*queen + 2*(rook+knight+bishop) +# King value is set to twice this value such that if the opponent is +# 8 queens up, but we got the king, we still exceed MATE_VALUE. +# When a MATE is detected, we'll set the score to MATE_UPPER - plies to get there +# E.g. Mate in 3 will be MATE_UPPER - 6 +MATE_LOWER = piece['K'] - 10*piece['Q'] +MATE_UPPER = piece['K'] + 10*piece['Q'] + +# The table size is the maximum number of elements in the transposition table. +TABLE_SIZE = 1e7 + +# Constants for tuning search +QS_LIMIT = 219 +EVAL_ROUGHNESS = 13 +DRAW_TEST = True + + +############################################################################### +# Chess logic +############################################################################### + +class Position(namedtuple('Position', 'board score wc bc ep kp')): + """ A state of a chess game + board -- a 120 char representation of the board + score -- the board evaluation + wc -- the castling rights, [west/queen side, east/king side] + bc -- the opponent castling rights, [west/king side, east/queen side] + ep - the en passant square + kp - the king passant square + """ + + def gen_moves(self): + # For each of our pieces, iterate through each possible 'ray' of moves, + # as defined in the 'directions' map. The rays are broken e.g. by + # captures or immediately in case of pieces such as knights. + for i, p in enumerate(self.board): + if not p.isupper(): continue + for d in directions[p]: + for j in count(i+d, d): + q = self.board[j] + # Stay inside the board, and off friendly pieces + if q.isspace() or q.isupper(): break + # Pawn move, double move and capture + if p == 'P' and d in (N, N+N) and q != '.': break + if p == 'P' and d == N+N and (i < A1+N or self.board[i+N] != '.'): break + if p == 'P' and d in (N+W, N+E) and q == '.' \ + and j not in (self.ep, self.kp, self.kp-1, self.kp+1): break + # Move it + yield (i, j) + # Stop crawlers from sliding, and sliding after captures + if p in 'PNK' or q.islower(): break + # Castling, by sliding the rook next to the king + if i == A1 and self.board[j+E] == 'K' and self.wc[0]: yield (j+E, j+W) + if i == H1 and self.board[j+W] == 'K' and self.wc[1]: yield (j+W, j+E) + + def rotate(self): + ''' Rotates the board, preserving enpassant ''' + return Position( + self.board[::-1].swapcase(), -self.score, self.bc, self.wc, + 119-self.ep if self.ep else 0, + 119-self.kp if self.kp else 0) + + def nullmove(self): + ''' Like rotate, but clears ep and kp ''' + return Position( + self.board[::-1].swapcase(), -self.score, + self.bc, self.wc, 0, 0) + + def move(self, move): + i, j = move + p, q = self.board[i], self.board[j] + put = lambda board, i, p: board[:i] + p + board[i+1:] + # Copy variables and reset ep and kp + board = self.board + wc, bc, ep, kp = self.wc, self.bc, 0, 0 + score = self.score + self.value(move) + # Actual move + board = put(board, j, board[i]) + board = put(board, i, '.') + # Castling rights, we move the rook or capture the opponent's + if i == A1: wc = (False, wc[1]) + if i == H1: wc = (wc[0], False) + if j == A8: bc = (bc[0], False) + if j == H8: bc = (False, bc[1]) + # Castling + if p == 'K': + wc = (False, False) + if abs(j-i) == 2: + kp = (i+j)//2 + board = put(board, A1 if j < i else H1, '.') + board = put(board, kp, 'R') + # Pawn promotion, double move and en passant capture + if p == 'P': + if A8 <= j <= H8: + board = put(board, j, 'Q') + if j - i == 2*N: + ep = i + N + if j == self.ep: + board = put(board, j+S, '.') + # We rotate the returned position, so it's ready for the next player + return Position(board, score, wc, bc, ep, kp).rotate() + + def value(self, move): + i, j = move + p, q = self.board[i], self.board[j] + # Actual move + score = pst[p][j] - pst[p][i] + # Capture + if q.islower(): + score += pst[q.upper()][119-j] + # Castling check detection + if abs(j-self.kp) < 2: + score += pst['K'][119-j] + # Castling + if p == 'K' and abs(i-j) == 2: + score += pst['R'][(i+j)//2] + score -= pst['R'][A1 if j < i else H1] + # Special pawn stuff + if p == 'P': + if A8 <= j <= H8: + score += pst['Q'][j] - pst['P'][j] + if j == self.ep: + score += pst['P'][119-(j+S)] + return score + +############################################################################### +# Search logic +############################################################################### + +# lower <= s(pos) <= upper +Entry = namedtuple('Entry', 'lower upper') + +class Searcher: + def __init__(self): + self.tp_score = {} + self.tp_move = {} + self.history = set() + self.nodes = 0 + + def bound(self, pos, gamma, depth, root=True): + """ returns r where + s(pos) <= r < gamma if gamma > s(pos) + gamma <= r <= s(pos) if gamma <= s(pos)""" + self.nodes += 1 + + # Depth <= 0 is QSearch. Here any position is searched as deeply as is needed for + # calmness, and from this point on there is no difference in behaviour depending on + # depth, so so there is no reason to keep different depths in the transposition table. + depth = max(depth, 0) + + # Sunfish is a king-capture engine, so we should always check if we + # still have a king. Notice since this is the only termination check, + # the remaining code has to be comfortable with being mated, stalemated + # or able to capture the opponent king. + if pos.score <= -MATE_LOWER: + return -MATE_UPPER + + # We detect 3-fold captures by comparing against previously + # _actually played_ positions. + # Note that we need to do this before we look in the table, as the + # position may have been previously reached with a different score. + # This is what prevents a search instability. + # FIXME: This is not true, since other positions will be affected by + # the new values for all the drawn positions. + if DRAW_TEST: + if not root and pos in self.history: + return 0 + + # Look in the table if we have already searched this position before. + # We also need to be sure, that the stored search was over the same + # nodes as the current search. + entry = self.tp_score.get((pos, depth, root), Entry(-MATE_UPPER, MATE_UPPER)) + if entry.lower >= gamma and (not root or self.tp_move.get(pos) is not None): + return entry.lower + if entry.upper < gamma: + return entry.upper + + # Here extensions may be added + # Such as 'if in_check: depth += 1' + + # Generator of moves to search in order. + # This allows us to define the moves, but only calculate them if needed. + def moves(): + # First try not moving at all. We only do this if there is at least one major + # piece left on the board, since otherwise zugzwangs are too dangerous. + if depth > 0 and not root and any(c in pos.board for c in 'RBNQ'): + yield None, -self.bound(pos.nullmove(), 1-gamma, depth-3, root=False) + # For QSearch we have a different kind of null-move, namely we can just stop + # and not capture anything else. + if depth == 0: + yield None, pos.score + # Then killer move. We search it twice, but the tp will fix things for us. + # Note, we don't have to check for legality, since we've already done it + # before. Also note that in QS the killer must be a capture, otherwise we + # will be non deterministic. + killer = self.tp_move.get(pos) + if killer and (depth > 0 or pos.value(killer) >= QS_LIMIT): + yield killer, -self.bound(pos.move(killer), 1-gamma, depth-1, root=False) + # Then all the other moves + for move in sorted(pos.gen_moves(), key=pos.value, reverse=True): + #for val, move in sorted(((pos.value(move), move) for move in pos.gen_moves()), reverse=True): + # If depth == 0 we only try moves with high intrinsic score (captures and + # promotions). Otherwise we do all moves. + if depth > 0 or pos.value(move) >= QS_LIMIT: + yield move, -self.bound(pos.move(move), 1-gamma, depth-1, root=False) + + # Run through the moves, shortcutting when possible + best = -MATE_UPPER + for move, score in moves(): + best = max(best, score) + if best >= gamma: + # Clear before setting, so we always have a value + if len(self.tp_move) > TABLE_SIZE: self.tp_move.clear() + # Save the move for pv construction and killer heuristic + self.tp_move[pos] = move + break + + # Stalemate checking is a bit tricky: Say we failed low, because + # we can't (legally) move and so the (real) score is -infty. + # At the next depth we are allowed to just return r, -infty <= r < gamma, + # which is normally fine. + # However, what if gamma = -10 and we don't have any legal moves? + # Then the score is actaully a draw and we should fail high! + # Thus, if best < gamma and best < 0 we need to double check what we are doing. + # This doesn't prevent sunfish from making a move that results in stalemate, + # but only if depth == 1, so that's probably fair enough. + # (Btw, at depth 1 we can also mate without realizing.) + if best < gamma and best < 0 and depth > 0: + is_dead = lambda pos: any(pos.value(m) >= MATE_LOWER for m in pos.gen_moves()) + if all(is_dead(pos.move(m)) for m in pos.gen_moves()): + in_check = is_dead(pos.nullmove()) + best = -MATE_UPPER if in_check else 0 + + # Clear before setting, so we always have a value + if len(self.tp_score) > TABLE_SIZE: self.tp_score.clear() + # Table part 2 + if best >= gamma: + self.tp_score[pos, depth, root] = Entry(best, entry.upper) + if best < gamma: + self.tp_score[pos, depth, root] = Entry(entry.lower, best) + + return best + + def search(self, pos, history=()): + """ Iterative deepening MTD-bi search """ + self.nodes = 0 + if DRAW_TEST: + self.history = set(history) + # print('# Clearing table due to new history') + self.tp_score.clear() + + # In finished games, we could potentially go far enough to cause a recursion + # limit exception. Hence we bound the ply. + for depth in range(1, 1000): + # The inner loop is a binary search on the score of the position. + # Inv: lower <= score <= upper + # 'while lower != upper' would work, but play tests show a margin of 20 plays + # better. + lower, upper = -MATE_UPPER, MATE_UPPER + while lower < upper - EVAL_ROUGHNESS: + gamma = (lower+upper+1)//2 + score = self.bound(pos, gamma, depth) + if score >= gamma: + lower = score + if score < gamma: + upper = score + # We want to make sure the move to play hasn't been kicked out of the table, + # So we make another call that must always fail high and thus produce a move. + self.bound(pos, lower, depth) + # If the game hasn't finished we can retrieve our move from the + # transposition table. + yield depth, self.tp_move.get(pos), self.tp_score.get((pos, depth, True)).lower + + +############################################################################### +# User interface +############################################################################### + +# Python 2 compatability +if sys.version_info[0] == 2: + input = raw_input + + +def parse(c): + fil, rank = ord(c[0]) - ord('a'), int(c[1]) - 1 + return A1 + fil - 10*rank + + +def render(i): + rank, fil = divmod(i - A1, 10) + return chr(fil + ord('a')) + str(-rank + 1) + + +def print_pos(pos): + print() + uni_pieces = {'R':'♜', 'N':'♞', 'B':'♝', 'Q':'♛', 'K':'♚', 'P':'♟', + 'r':'♖', 'n':'♘', 'b':'♗', 'q':'♕', 'k':'♔', 'p':'♙', '.':'·'} + for i, row in enumerate(pos.board.split()): + print(' ', 8-i, ' '.join(uni_pieces.get(p, p) for p in row)) + print(' a b c d e f g h \n\n') + + +def main(): + hist = [Position(initial, 0, (True,True), (True,True), 0, 0)] + searcher = Searcher() + while True: + print_pos(hist[-1]) + + if hist[-1].score <= -MATE_LOWER: + print("You lost") + break + + # We query the user until she enters a (pseudo) legal move. + move = None + while move not in hist[-1].gen_moves(): + match = re.match('([a-h][1-8])'*2, input('Your move: ')) + if match: + move = parse(match.group(1)), parse(match.group(2)) + else: + # Inform the user when invalid input (e.g. "help") is entered + print("Please enter a move like g8f6") + hist.append(hist[-1].move(move)) + + # After our move we rotate the board and print it again. + # This allows us to see the effect of our move. + print_pos(hist[-1].rotate()) + + if hist[-1].score <= -MATE_LOWER: + print("You won") + break + + # Fire up the engine to look for a move. + start = time.time() + for _depth, move, score in searcher.search(hist[-1], hist): + if time.time() - start > 1: + break + + if score == MATE_UPPER: + print("Checkmate!") + + # The black player moves from a rotated position, so we have to + # 'back rotate' the move before printing it. + print("My move:", render(119-move[0]) + render(119-move[1])) + hist.append(hist[-1].move(move)) + + +if __name__ == '__main__': + main() + diff --git a/plugin/info/__init__.py b/plugin/info/__init__.py index 7c667c8..c9a6016 100644 --- a/plugin/info/__init__.py +++ b/plugin/info/__init__.py @@ -2,11 +2,7 @@ import subprocess from cinput import ControlInput, Button from graphics import Graphics -def execute(cinput: ControlInput, graphics: Graphics): - graphics.clear() - graphics.text("Loading details...", 0, 0, 1) - graphics.show() - +def execute(cinput: ControlInput, graphics: Graphics, _): while True: cmd = "hostname -I | cut -d' ' -f1" ip = subprocess.check_output(cmd, shell=True).decode("utf-8")