started replacing sunfish with stockfish

This commit is contained in:
Rudis Muiznieks 2022-04-26 08:02:42 -05:00
parent ee1ca2f653
commit 0396644151
Signed by: rudism
GPG key ID: CABF2F86EF7884F9
5 changed files with 56 additions and 535 deletions

View file

@ -1,6 +1,7 @@
import os
import signal
import time
from traceback import print_exc
from importlib import import_module
from icecream import ic
from menu import Menu, MenuItem, MenuType
@ -55,8 +56,8 @@ try:
plugin = import_module("plugin." + item.data["plugin"])
ic(plugin)
plugin.execute(cinput, graphics, item.data["arg"])
except Exception as e:
ic(e)
except:
print_exc()
graphics.clear()
graphics.text("Plugin error!", 0, 0, 1)
graphics.show()
@ -64,7 +65,6 @@ try:
elif item.menu_type == MenuType.EXIT_CMD:
os.system(item.data["command"])
program_exit()
except Exception as e:
ic(e)
except:
print_exc()
program_exit()

View file

@ -1,6 +1,6 @@
from icecream import ic
from graphics import Graphics
from .sunfish import Position
from chess import Board, Color, WHITE, BLACK
class Draw:
BOARD_SIZE = 64
@ -79,9 +79,11 @@ class Draw:
self._graphics.fill_rect(
self.BOARD_SIZE, 0, self.BOARD_SIZE, self.BOARD_SIZE, 0)
def draw_board(self, pos: Position, player_color: str):
def draw_board(self, board: Board, player_color: Color):
ic(board)
self._graphics.fill_rect(0, 0, self.BOARD_SIZE, self.BOARD_SIZE, 0)
nb = "".join(pos.board.split()) if player_color == "w" else "".join(pos.rotate().board.split())[::-1]
nb = "".join(str(board).split()).replace(" ", "")
nb = nb if player_color == WHITE else nb[::-1]
c = True
for row in range(8):
for col in range(8):

View file

@ -1,11 +1,12 @@
from os import path
from enum import Enum, auto
import time
import shutil
from icecream import ic
from cinput import ControlInput, Button
from graphics import Graphics
from .draw import Draw
from . import sunfish
from . import util
from chess import Board, Move, WHITE, BLACK
from stockfish import Stockfish
class GameState(Enum):
MAIN_MENU = auto()
@ -19,35 +20,33 @@ class ChessGame:
MAX_THINKING_SECONDS = 3
def __init__(self, cinput: ControlInput, graphics: Graphics):
self._sf = Stockfish(shutil.which("stockfish") or "stockfish")
self._cinput = cinput
self._draw = Draw(graphics)
self._state = GameState.MAIN_MENU
self._menu_index = 0
self._searcher = sunfish.Searcher()
def _init_new_game(self, pos: sunfish.Position):
self._hist = [pos]
def _init_new_game(self, fen = None):
self._board = Board() if fen == None else Board(fen)
self._last_move = "Begin!"
self._all_moves = dict()
self._src_idx = 0
self._dst_idx = 0
self._draw.draw_board(self._hist[-1], self._player_color)
self._player_color = self._board.turn
self._sf.set_fen_position(self._board.fen())
self._draw.draw_board(self._board, self._player_color)
def _load_saved_or_init(self):
if path.exists(self.SAVE_FILE):
with open(self.SAVE_FILE, "r") as fen:
fenstr = fen.read()
pos = util.parseFEN(fenstr)
self._player_color = util.get_color(pos)
self._init_new_game(pos)
fen = fen.read()
self._init_new_game(fen)
else:
self._player_color = "w"
self._init_new_game(sunfish.Position(
sunfish.initial, 0, (True,True), (True,True), 0, 0))
self._init_new_game()
def _save_game(self):
with open(self.SAVE_FILE, "w") as fen:
fen.write(util.renderFEN(self._hist[-1]))
fen.write(self._board.fen())
def run(self):
# either load the save game or start a new one
@ -55,8 +54,7 @@ class ChessGame:
while True:
key = None
move = None
src = ""
src = None
################
# HANDLE STATE #
@ -70,25 +68,30 @@ class ChessGame:
# computer makes a move
elif self._state == GameState.THINKING:
self._draw.draw_thinking(self._last_move)
start = time.time()
for _, self._move, self._score in self._searcher.search(
self._hist[-1], self._hist):
if time.time() - start > self.MAX_THINKING_SECONDS:
break
self._hist.append(self._hist[-1].move(self._move))
self._draw.draw_board(self._hist[-1], self._player_color)
self._last_move = util.move_str(move, self._player_color == "w")
# reset user moves and move state to user's turn
self._all_moves = dict()
self._state = GameState.CHOOSE_SRC
# TODO: check game over
self._move = self._sf.get_best_move()
if self._move != None:
self._board.push(Move.from_uci(self._move))
# reset user moves and move state to user's turn
self._all_moves = dict()
self._src_idx = 0
self._dst_idx = 0
self._state = GameState.CHOOSE_SRC
else:
self._state = GameState.GAME_OVER
self._draw.draw_board(self._board, self._player_color)
# user picks source piece
elif self._state == GameState.CHOOSE_SRC:
if len(list(self._all_moves)) == 0:
self._all_moves = util.get_all_moves(self._hist[-1])
src = list(self._all_moves)[self._src_idx]
self._draw.draw_select(self._last_move, src)
key = self._cinput.get_one_shot()
for src, dst in map(lambda m: [Move.uci(m)[0:2], Move.uci(m)[2:4]], self._board.legal_moves):
self._all_moves.setdefault(src, []).append(dst)
if len(list(self._all_moves > 0)):
src = list(self._all_moves)[self._src_idx]
self._draw.draw_select(self._last_move, src)
key = self._cinput.get_one_shot()
else:
self._state = GameState.GAME_OVER
# user picks dest square
elif self._state == GameState.CHOOSE_DST:
@ -126,18 +129,13 @@ class ChessGame:
self._save_game()
return
elif self._menu_index == 2 or self._menu_index == 3: # new game
pos = sunfish.Position(
sunfish.initial, 0, (True,True), (True,True), 0, 0)
if self._menu_index == 3:
self._player_color = "b"
self._state = GameState.THINKING
else:
self._player_color = "w"
self._init_new_game()
if self._menu_index == 2:
self._player_color = WHITE
self._state = GameState.CHOOSE_SRC
self._init_new_game(pos)
# TODO: figure out why I need to do this
self._draw.draw_board(
pos if self._player_color == "w" else pos.rotate(), self._player_color)
else:
self._player_color = BLACK
self._state = GameState.THINKING
# handle user input when selecting source piece
# TODO: some kind of cursor indicator on board
@ -180,15 +178,15 @@ class ChessGame:
# make the move
elif key == Button.BTN_B:
dst = self._all_moves[src][self._dst_idx]
# TODO: fix rotation depending on player color
self._last_move = src + " - " + dst
sfmove = sunfish.parse(src), sunfish.parse(dst)
self._hist.append(self._hist[-1].move(sfmove))
self._draw.draw_board(self._hist[-1].rotate(), self._player_color)
self._move = src + dst
self._board.append(Move.from_uci(self._move))
self._draw.draw_board(self._board, self._player_color)
# TODO: check game over
self._state = GameState.THINKING
# handle user input on game over
else:
if key == Button.BTN_A or key == Button.BTN_B:
self._state = GameState.MAIN_MENU

View file

@ -1,438 +0,0 @@
#!/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
###############################################################################
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 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()

View file

@ -1,41 +0,0 @@
import re
import itertools
from . import sunfish
def get_all_moves(pos: sunfish.Position):
all_moves = dict()
for src, dst in pos.gen_moves():
all_moves.setdefault(sunfish.render(src), []).append(sunfish.render(dst))
return all_moves
def move_str(move, shift = False):
s = -119 if shift else 0
return sunfish.render(s + move[0]) + " - " + sunfish.render(s + move[1])
def get_color(pos):
return "wb"[1 if pos.board.startswith("\n") else 0]
def parseFEN(fen):
board, color, castling, enpas, _, _ = fen.split()
board = re.sub(r"\d", (lambda m: "."*int(m.group(0))), board)
board = list(21*" " + " ".join(board.split("/")) + 21*" ")
board[9::10] = ["\n"]*12
board = "".join(board)
wc = ("Q" in castling, "K" in castling)
bc = ("k" in castling, "q" in castling)
ep = sunfish.parse(enpas) if enpas != "-" else 0
score = sum(sunfish.pst[p][i] for i,p in enumerate(board) if p.isupper())
score -= sum(sunfish.pst[p.upper()][119-i] for i,p in enumerate(board) if p.islower())
pos = sunfish.Position(board, score, wc, bc, ep, 0)
return pos if color == "w" else pos.rotate()
def renderFEN(pos, half_move_clock=0, full_move_clock=1):
color = get_color(pos)
if color == "b":
pos = pos.rotate()
board = "/".join(pos.board.split())
board = re.sub(r"\.+", (lambda m: str(len(m.group(0)))), board)
castling = "".join(itertools.compress("KQkq", pos.wc[::-1]+pos.bc)) or "-"
ep = sunfish.render(pos.ep) if not pos.board[pos.ep].isspace() else "-"
clock = "{} {}".format(half_move_clock, full_move_clock)
return " ".join((board, color, castling, ep, clock))