1 2import asyncio 3import itertools 4import re 5 6from gi.repository import Gtk, GObject 7 8 9from pychess.compat import create_task 10from pychess.Utils import wait_signal 11from pychess.System import conf 12from pychess.System.Log import log 13from pychess.widgets import mainwindow 14from pychess.Utils.Move import Move 15from pychess.Utils.Board import Board 16from pychess.Utils.Cord import Cord 17from pychess.Utils.Move import toSAN, toAN, parseAny 18from pychess.Utils.Offer import Offer 19from pychess.Utils.const import ANALYZING, INVERSE_ANALYZING, DRAW, WHITEWON, BLACKWON, \ 20 WON_ADJUDICATION, DRAW_OFFER, ACTION_ERROR_NONE_TO_ACCEPT, CASTLE_KK, WHITE, \ 21 CASTLE_SAN, FISCHERRANDOMCHESS, BLACK, reprSign, RESIGNATION 22from pychess.Utils.logic import validate, getMoveKillingKing 23from pychess.Utils.lutils.ldata import MATE_VALUE 24from pychess.Utils.lutils.lmove import ParsingError 25from pychess.Variants import variants 26from pychess.Players.Player import PlayerIsDead, TurnInterrupt, InvalidMove 27from .ProtocolEngine import ProtocolEngine, TIME_OUT_SECOND 28 29 30movere = re.compile(r""" 31 ( # group start 32 (?: # non grouping parenthesis start 33 [PKQRBN]? # piece 34 [a-h]?[1-8]? # unambiguous column or line 35 x? # capture 36 @? # drop 37 [a-h][1-8] # destination square 38 =?[QRBN]? # promotion 39 |O\-O(?:\-O)? # castling 40 |0\-0(?:\-0)? # castling 41 ) # non grouping parenthesis end 42 [+#]? # check/mate 43 ) # group end 44 \s* # any whitespace 45 """, re.VERBOSE) 46 47d_plus_dot_expr = re.compile(r"\d+\.") 48 49anare = re.compile(r""" 50 ^ # beginning of string 51 (\s* # 52 \d+ [+\-\.]? # The ply analyzed. Some engines end it with a dot, minus or plus 53 \s+) # 54 (-?Mat\s*\d+ | [+\-\d\.]+) # The score found in centipawns. 55 # Mat1 is used by gnuchess to specify mate in one. 56 # otherwise we should support a signed float 57 \s+ # 58 ([\d\.]+) # The time used in centi-seconds 59 \s+ # 60 ([\d\.]+) # Number of nodes visited 61 \s+ # 62 (.+) # The Principal-Variation. With or without move numbers 63 \s* # 64 $ # end of string 65 """, re.VERBOSE) 66 67# anare = re.compile("\(d+)\.?\s+ (Mat\d+|[-\d\.]+) \s+ \d+\s+\d+\s+((?:%s\s*)+)" % mov) 68 69whitespaces = re.compile(r"\s+") 70 71 72# There is no way in the CECP protocol to determine if an engine not answering 73# the protover=2 handshake with done=1 is old or just very slow. Thus we 74# need a timeout after which we conclude the engine is 'protover=1' and will 75# never answer. 76# XBoard will only give 2 seconds, but as we are quite sure that 77# the engines support the protocol, we can add more. We don't add 78# infinite time though, just in case. 79# The engine can get more time by sending done=0 80 81 82class CECPEngine(ProtocolEngine): 83 def __init__(self, subprocess, color, protover, md5): 84 ProtocolEngine.__init__(self, subprocess, color, protover, md5) 85 86 self.features = { 87 "ping": 0, 88 "setboard": 0, 89 "playother": 0, 90 "san": 0, 91 "usermove": 0, 92 "time": 1, 93 "draw": 1, 94 "sigint": 0, 95 "sigterm": 0, 96 "reuse": 0, 97 "analyze": 0, 98 "myname": ', '.join(self.defname), 99 "variants": None, 100 "colors": 1, 101 "ics": 0, 102 "name": 0, 103 "pause": 0, 104 "nps": 0, 105 "debug": 0, 106 "memory": 0, 107 "smp": 0, 108 "egt": '', 109 "option": '', 110 "exclude": 0, 111 "done": None, 112 } 113 114 self.supported_features = [ 115 "ping", "setboard", "san", "usermove", "time", "draw", "sigint", 116 "analyze", "myname", "variants", "colors", "pause", "done", "egt", 117 "debug", "smp", "memory", "option" 118 ] 119 120 self.options = {} 121 self.options["Ponder"] = {"name": "Ponder", 122 "type": "check", 123 "default": False} 124 125 self.name = None 126 127 self.board = Board(setup=True) 128 129 # if self.engineIsInNotPlaying == True, engine is in "force" mode, 130 # i.e. not thinking or playing, but still verifying move legality 131 self.engineIsInNotPlaying = False 132 self.engineIsAnalyzing = False 133 self.movenext = False 134 self.waitingForMove = False 135 self.readyForMoveNowCommand = False 136 self.timeHandicap = 1 137 138 self.lastping = 0 139 self.lastpong = 0 140 141 self.queue = asyncio.Queue() 142 self.parse_line_task = create_task(self.parseLine(self.engine)) 143 self.died_cid = self.engine.connect("died", lambda e: self.queue.put_nowait("die")) 144 self.invalid_move = None 145 146 self.optionQueue = [] 147 self.undoQueue = [] 148 self.ready_moves_event = asyncio.Event() 149 150 self.cids = [ 151 self.connect_after("readyForOptions", self.__onReadyForOptions), 152 self.connect_after("readyForMoves", self.__onReadyForMoves), 153 ] 154 155 # Starting the game 156 157 def prestart(self): 158 print("xboard", file=self.engine) 159 if self.protover == 1: 160 # start a new game (CECPv1 engines): 161 print("new", file=self.engine) 162 163 # we are now ready for options: 164 self.emit("readyForOptions") 165 elif self.protover == 2: 166 # start advanced protocol initialisation: 167 print("protover 2", file=self.engine) 168 169 # we don't start a new game for CECPv2 here, 170 # we will do it after feature accept/reject is completed. 171 172 def start(self, event, is_dead): 173 create_task(self.__startBlocking(event, is_dead)) 174 175 @asyncio.coroutine 176 def __startBlocking(self, event, is_dead): 177 if self.protover == 1: 178 self.emit("readyForMoves") 179 return_value = "ready" 180 181 if self.protover == 2: 182 try: 183 return_value = yield from asyncio.wait_for(self.queue.get(), TIME_OUT_SECOND) 184 if return_value == "not ready": 185 return_value = yield from asyncio.wait_for(self.queue.get(), TIME_OUT_SECOND) 186 # Gaviota sends done=0 after "xboard" and after "protover 2" too 187 if return_value == "not ready": 188 return_value = yield from asyncio.wait_for(self.queue.get(), TIME_OUT_SECOND) 189 except asyncio.TimeoutError: 190 log.warning("Got timeout error", extra={"task": self.defname}) 191 is_dead.add(True) 192 except Exception: 193 log.warning("Unknown error", extra={"task": self.defname}) 194 is_dead.add(True) 195 else: 196 if return_value == "die": 197 is_dead.add(True) 198 assert return_value == "ready" or return_value == "del" 199 200 if event is not None: 201 event.set() 202 203 def __onReadyForOptions(self, self_): 204 # We always want post turned on so the Engine Output sidebar can 205 # show those things -Jonas Thiem 206 print("post", file=self.engine) 207 208 for command in self.optionQueue: 209 print(command, file=self.engine) 210 211 def __onReadyForMoves(self, self_): 212 if self.mode in (ANALYZING, INVERSE_ANALYZING): 213 # workaround for crafty not sending analysis after it has found a mating line 214 # http://code.google.com/p/pychess/issues/detail?id=515 215 if "crafty" in self.features["myname"].lower(): 216 print("noise 0", file=self.engine) 217 218 self.__sendAnalyze(self.mode == INVERSE_ANALYZING) 219 self.ready_moves_event.set() 220 self.readyMoves = True 221 222 # Ending the game 223 224 def end(self, status, reason): 225 self.parse_line_task.cancel() 226 if self.engine.handler_is_connected(self.died_cid): 227 self.engine.disconnect(self.died_cid) 228 if self.handler_is_connected(self.analyze_cid): 229 self.disconnect(self.analyze_cid) 230 for cid in self.cids: 231 if self.handler_is_connected(cid): 232 self.disconnect(cid) 233 self.board = None 234 235 if self.connected: 236 # We currently can't fillout the comment "field" as the repr strings 237 # for reasons and statuses lies in Main.py 238 # Creating Status and Reason class would solve this 239 if status == DRAW: 240 print("result 1/2-1/2 {?}", file=self.engine) 241 elif status == WHITEWON: 242 print("result 1-0 {?}", file=self.engine) 243 elif status == BLACKWON: 244 print("result 0-1 {?}", file=self.engine) 245 else: 246 print("result * {?}", file=self.engine) 247 248 if reason == WON_ADJUDICATION: 249 self.queue.put_nowait("invalid") 250 251 # Make sure the engine exits and do some cleaning 252 self.kill(reason) 253 254 def kill(self, reason): 255 """ Kills the engine, starting with the 'quit' command, then sigterm and 256 eventually sigkill. 257 Returns the exitcode, or if engine have already been killed, returns 258 None """ 259 if self.connected: 260 self.connected = False 261 try: 262 try: 263 print("quit", file=self.engine) 264 self.queue.put_nowait("del") 265 self.engine.terminate() 266 267 except OSError as err: 268 # No need to raise on a hang up error, as the engine is dead 269 # anyways 270 if err.errno == 32: 271 log.warning("Hung up Error", extra={"task": self.defname}) 272 return err.errno 273 else: 274 raise 275 276 finally: 277 # Clear the analyzed data, if any 278 self.emit("analyze", []) 279 280 # Send the player move updates 281 282 def set_board(self, board): 283 self.setBoardList([board], []) 284 285 def setBoard(self, board, search=True): 286 def coro(): 287 if self.engineIsAnalyzing: 288 self.__stop_analyze() 289 yield from asyncio.sleep(0.1) 290 291 self.setBoardList([board], []) 292 if search: 293 self.__sendAnalyze(self.mode == INVERSE_ANALYZING) 294 create_task(coro()) 295 296 def putMove(self, board1, move, board2): 297 """ Sends the engine the last move made (for spectator engines). 298 @param board1: The current board 299 @param move: The last move made 300 @param board2: The board before the last move was made 301 """ 302 def coro(): 303 if self.engineIsAnalyzing: 304 self.__stop_analyze() 305 yield from asyncio.sleep(0.1) 306 307 self.setBoardList([board1], []) 308 if not self.analyzing_paused: 309 self.__sendAnalyze(self.mode == INVERSE_ANALYZING) 310 create_task(coro()) 311 312 @asyncio.coroutine 313 def makeMove(self, board1, move, board2): 314 """ Gets a move from the engine (for player engines). 315 @param board1: The current board 316 @param move: The last move made 317 @param board2: The board before the last move was made 318 @return: The move the engine decided to make 319 """ 320 log.debug("makeMove: move=%s self.movenext=%s board1=%s board2=%s self.board=%s" % ( 321 move, self.movenext, board1, board2, self.board), extra={"task": self.defname}) 322 assert self.readyMoves 323 324 if self.board == board1 or not board2 or self.movenext: 325 self.board = board1 326 self.__tellEngineToPlayCurrentColorAndMakeMove() 327 self.movenext = False 328 else: 329 self.board = board1 330 self.__usermove(board2, move) 331 332 if self.engineIsInNotPlaying: 333 self.__tellEngineToPlayCurrentColorAndMakeMove() 334 335 self.waitingForMove = True 336 self.readyForMoveNowCommand = True 337 338 # Parse outputs 339 status = yield from self.queue.get() 340 if status == "not ready": 341 log.warning( 342 "Engine seems to be protover=2, but is treated as protover=1", 343 extra={"task": self.defname}) 344 status = yield from self.queue.get() 345 if status == "ready": 346 status = yield from self.queue.get() 347 if status == "invalid": 348 raise InvalidMove 349 if status == "del" or status == "die": 350 raise PlayerIsDead("Killed by foreign forces") 351 if status == "int": 352 raise TurnInterrupt 353 354 self.waitingForMove = False 355 self.readyForMoveNowCommand = False 356 assert isinstance(status, Move), status 357 return status 358 359 def updateTime(self, secs, opsecs): 360 if self.features["time"]: 361 print("time %s" % int(secs * 100 * self.timeHandicap), 362 file=self.engine) 363 print("otim %s" % int(opsecs * 100), file=self.engine) 364 365 # Standard options 366 367 def setOptionAnalyzing(self, mode): 368 self.mode = mode 369 370 def setOptionInitialBoard(self, model): 371 @asyncio.coroutine 372 def coro(): 373 yield from self.ready_moves_event.wait() 374 # We don't use the optionQueue here, as set board prints a whole lot of 375 # stuff. Instead we just call it. 376 self.setBoardList(model.boards[:], model.moves[:]) 377 create_task(coro()) 378 379 def setBoardList(self, boards, moves): 380 # Notice: If this method is to be called while playing, the engine will 381 # need 'new' and an arrangement similar to that of 'pause' to avoid 382 # the current thought move to appear 383 if self.mode not in (ANALYZING, INVERSE_ANALYZING): 384 self.__tellEngineToStopPlayingCurrentColor() 385 386 self.__setBoard(boards[0]) 387 388 self.board = boards[-1] 389 for board, move in zip(boards[:-1], moves): 390 self.__usermove(board, move) 391 392 if self.mode in (ANALYZING, INVERSE_ANALYZING): 393 self.board = boards[-1] 394 if self.mode == INVERSE_ANALYZING: 395 self.board = self.board.switchColor() 396 397 # The called of setBoardList will have to repost/analyze the 398 # analyzer engines at this point. 399 400 def setOptionVariant(self, variant): 401 if self.features["variants"] is None: 402 log.warning("setOptionVariant: engine doesn't support variants", 403 extra={"task": self.defname}) 404 return 405 406 if variant in variants.values() and not variant.standard_rules: 407 assert variant.cecp_name in self.features["variants"], \ 408 "%s doesn't support %s variant" % (self, variant.cecp_name) 409 self.optionQueue.append("variant %s" % variant.cecp_name) 410 411 # Strength system # 412 # Strength Depth Ponder Time handicap # 413 # 1 1 o 1,258% # 414 # 2 2 o 1,584% # 415 # 3 3 o 1.995% # 416 # # 417 # 19 o x 79,43% # 418 # 20 o x o # 419 420 def setOptionStrength(self, strength, forcePonderOff): 421 self.strength = strength 422 423 if strength <= 19: 424 self.__setTimeHandicap(0.01 * 10 ** (strength / 10.)) 425 426 if strength <= 18: 427 self.__setDepth(strength) 428 429 # Crafty ofers 100 skill levels 430 if "crafty" in self.features["myname"].lower() and strength <= 19: 431 self.optionQueue.append("skill %s" % strength * 5) 432 433 self.__setPonder(strength >= 19 and not forcePonderOff) 434 435 if strength == 20: 436 if "gaviota" in self.features["egt"]: 437 self.optionQueue.append("egtpath gaviota %s" % conf.get("egtb_path")) 438 else: 439 self.optionQueue.append("random") 440 441 def __setDepth(self, depth): 442 self.optionQueue.append("sd %d" % depth) 443 444 def __setTimeHandicap(self, timeHandicap): 445 self.timeHandicap = timeHandicap 446 447 def __setPonder(self, ponder): 448 if ponder: 449 self.optionQueue.append("hard") 450 else: 451 self.optionQueue.append("hard") 452 self.optionQueue.append("easy") 453 454 def setOptionTime(self, secs, gain, moves): 455 # Notice: In CECP we apply time handicap in updateTime, not in 456 # setOptionTime. 457 458 minutes = int(secs / 60) 459 secs = int(secs % 60) 460 mins = str(minutes) 461 if secs: 462 mins += ":" + str(secs) 463 464 self.optionQueue.append("level %s %s %d" % (moves, mins, gain)) 465 466 # Option handling 467 468 def setOption(self, key, value): 469 """ Set an option, which will be sent to the engine, after the 470 'readyForOptions' signal has passed. 471 If you want to know the possible options, you should go to 472 engineDiscoverer or use the hasOption method 473 while you are in your 'readyForOptions' signal handler """ 474 if self.readyMoves: 475 log.warning( 476 "Options set after 'readyok' are not sent to the engine", 477 extra={"task": self.defname}) 478 if key == "cores": 479 self.optionQueue.append("cores %s" % value) 480 elif key == "memory": 481 self.optionQueue.append("memory %s" % value) 482 elif key.lower() == "ponder": 483 self.__setPonder(value == 1) 484 else: 485 self.optionQueue.append("option %s=%s" % (key, value)) 486 487 # Interacting with the player 488 489 def pause(self): 490 """ Pauses engine using the "pause" command if available. Otherwise put 491 engine in force mode. By the specs the engine shouldn't ponder in 492 force mode, but some of them do so anyways. """ 493 494 log.debug("pause: self=%s" % self, extra={"task": self.defname}) 495 if self.isAnalyzing(): 496 self.__stop_analyze() 497 self.analyzing_paused = True 498 else: 499 self.engine.pause() 500 return 501 502 def resume(self): 503 log.debug("resume: self=%s" % self, extra={"task": self.defname}) 504 if self.isAnalyzing(): 505 self.__sendAnalyze(self.mode == INVERSE_ANALYZING) 506 self.analyzing_paused = False 507 else: 508 self.engine.resume() 509 return 510 511 def hurry(self): 512 log.debug("hurry: self.waitingForMove=%s self.readyForMoveNowCommand=%s" % ( 513 self.waitingForMove, self.readyForMoveNowCommand), extra={"task": self.defname}) 514 if self.waitingForMove and self.readyForMoveNowCommand: 515 self.__tellEngineToMoveNow() 516 self.readyForMoveNowCommand = False 517 518 def spectatorUndoMoves(self, moves, gamemodel): 519 if self.analyzing_paused: 520 return 521 522 log.debug("spectatorUndoMoves: moves=%s gamemodel.ply=%s gamemodel.boards[-1]=%s self.board=%s" % ( 523 moves, gamemodel.ply, gamemodel.boards[-1], self.board), extra={"task": self.defname}) 524 525 for i in range(moves): 526 print("undo", file=self.engine) 527 528 self.board = gamemodel.boards[-1] 529 530 def playerUndoMoves(self, moves, gamemodel): 531 log.debug("CECPEngine.playerUndoMoves: moves=%s self=%s gamemodel.curplayer=%s" % 532 (moves, self, gamemodel.curplayer), extra={"task": self.defname}) 533 534 self.board = gamemodel.boards[-1] 535 536 self.__tellEngineToStopPlayingCurrentColor() 537 538 for i in range(moves): 539 print("undo", file=self.engine) 540 541 if gamemodel.curplayer != self and moves % 2 == 1 or \ 542 (gamemodel.curplayer == self and moves % 2 == 0): 543 # Interrupt if we were searching, but should no longer do so 544 log.debug("CECPEngine.playerUndoMoves: putting TurnInterrupt into self.move_queue %s" % self.name, extra={"task": self.defname}) 545 self.queue.put_nowait("int") 546 547 # Offer handling 548 549 def offer(self, offer): 550 if offer.type == DRAW_OFFER: 551 if self.features["draw"]: 552 print("draw", file=self.engine) 553 else: 554 self.emit("accept", offer) 555 556 def offerError(self, offer, error): 557 if self.features["draw"]: 558 # We don't keep track if engine draws are offers or accepts. We just 559 # Always assume they are accepts, and if they are not, we get this 560 # error and emit offer instead 561 if offer.type == DRAW_OFFER and error == ACTION_ERROR_NONE_TO_ACCEPT: 562 self.emit("offer", Offer(DRAW_OFFER)) 563 564 # Internal 565 566 def __usermove(self, board, move): 567 if self.features["usermove"]: 568 self.engine.write("usermove ") 569 570 if self.features["san"]: 571 print(toSAN(board, move), file=self.engine) 572 else: 573 castle_notation = CASTLE_KK 574 if board.variant == FISCHERRANDOMCHESS: 575 castle_notation = CASTLE_SAN 576 print( 577 toAN(board, 578 move, 579 short=True, 580 castleNotation=castle_notation), 581 file=self.engine) 582 583 def __tellEngineToMoveNow(self): 584 if self.features["sigint"]: 585 self.engine.sigint() 586 print("?", file=self.engine) 587 588 def __tellEngineToStopPlayingCurrentColor(self): 589 print("force", file=self.engine) 590 self.engineIsInNotPlaying = True 591 592 def __tellEngineToPlayCurrentColorAndMakeMove(self): 593 self.__printColor() 594 print("go", file=self.engine) 595 self.engineIsInNotPlaying = False 596 597 def __stop_analyze(self): 598 if self.engineIsAnalyzing: 599 print("exit", file=self.engine) 600 # Some engines (crafty, gnuchess) doesn't respond to exit command 601 # we try to force them to stop with an empty board fen 602 print("setboard 8/8/8/8/8/8/8/8 w - - 0 1", file=self.engine) 603 self.engineIsAnalyzing = False 604 605 def __sendAnalyze(self, inverse=False): 606 if inverse and self.board.board.opIsChecked(): 607 # Many engines don't like positions able to take down enemy 608 # king. Therefore we just return the "kill king" move 609 # automaticaly 610 self.emit("analyze", [(self.board.ply, [toAN( 611 self.board, getMoveKillingKing(self.board))], MATE_VALUE - 1, "1", "")]) 612 return 613 614 print("post", file=self.engine) 615 print("analyze", file=self.engine) 616 self.engineIsAnalyzing = True 617 618 if not conf.get("infinite_analysis"): 619 loop = asyncio.get_event_loop() 620 loop.call_later(conf.get("max_analysis_spin"), self.__stop_analyze) 621 622 def __printColor(self): 623 if self.features["colors"]: # or self.mode == INVERSE_ANALYZING: 624 if self.board.color == WHITE: 625 print("white", file=self.engine) 626 else: 627 print("black", file=self.engine) 628 629 def __setBoard(self, board): 630 if self.features["setboard"]: 631 self.__tellEngineToStopPlayingCurrentColor() 632 fen = board.asFen(enable_bfen=False) 633 if self.mode == INVERSE_ANALYZING: 634 fen_arr = fen.split() 635 if not self.board.board.opIsChecked(): 636 if fen_arr[1] == "b": 637 fen_arr[1] = "w" 638 else: 639 fen_arr[1] = "b" 640 fen = " ".join(fen_arr) 641 print("setboard %s" % fen, file=self.engine) 642 else: 643 # Kludge to set black to move, avoiding the troublesome and now 644 # deprecated "black" command. - Equal to the one xboard uses 645 self.__tellEngineToStopPlayingCurrentColor() 646 if board.color == BLACK: 647 print("a2a3", file=self.engine) 648 print("edit", file=self.engine) 649 print("#", file=self.engine) 650 for color in WHITE, BLACK: 651 for y_loc, row in enumerate(board.data): 652 for x_loc, piece in row.items(): 653 if not piece or piece.color != color: 654 continue 655 sign = reprSign[piece.sign] 656 cord = repr(Cord(x_loc, y_loc)) 657 print(sign + cord, file=self.engine) 658 print("c", file=self.engine) 659 print(".", file=self.engine) 660 661 # Parsing 662 663 @asyncio.coroutine 664 def parseLine(self, proc): 665 while True: 666 line = yield from wait_signal(proc, 'line') 667 if not line: 668 break 669 else: 670 line = line[1] 671 if line[0:1] == "#": 672 # Debug line which we shall ignore as specified in CECPv2 specs 673 continue 674 675 # log.debug("__parseLine: line=\"%s\"" % line.strip(), extra={"task":self.defname}) 676 parts = whitespaces.split(line.strip()) 677 if parts[0] == "pong": 678 self.lastpong = int(parts[1]) 679 continue 680 681 # Illegal Move 682 if parts[0].lower().find("illegal") >= 0: 683 log.warning("__parseLine: illegal move: line=\"%s\", board=%s" % ( 684 line.strip(), self.board), extra={"task": self.defname}) 685 if parts[-2] == "sd" and parts[-1].isdigit(): 686 print("depth", parts[-1], file=self.engine) 687 continue 688 689 # A Move (Perhaps) 690 if self.board: 691 if parts[0] == "move": 692 movestr = parts[1] 693 # Old Variation 694 elif d_plus_dot_expr.match(parts[0]) and parts[1] == "...": 695 movestr = parts[2] 696 else: 697 movestr = False 698 699 if movestr: 700 self.waitingForMove = False 701 self.readyForMoveNowCommand = False 702 if self.engineIsInNotPlaying: 703 # If engine was set in pause just before the engine sent its 704 # move, we ignore it. However the engine has to know that we 705 # ignored it, and thus we step it one back 706 log.info("__parseLine: Discarding engine's move: %s" % 707 movestr, 708 extra={"task": self.defname}) 709 print("undo", file=self.engine) 710 continue 711 else: 712 try: 713 move = parseAny(self.board, movestr) 714 except ParsingError: 715 self.invalid_move = movestr 716 log.info( 717 "__parseLine: ParsingError engine move: %s %s" 718 % (movestr, self.board), 719 extra={"task": self.defname}) 720 self.end(WHITEWON if self.board.color == BLACK else 721 BLACKWON, WON_ADJUDICATION) 722 continue 723 724 if validate(self.board, move): 725 self.board = None 726 self.queue.put_nowait(move) 727 continue 728 else: 729 self.invalid_move = movestr 730 log.info( 731 "__parseLine: can't validate engine move: %s %s" 732 % (movestr, self.board), 733 extra={"task": self.defname}) 734 self.end(WHITEWON if self.board.color == BLACK else 735 BLACKWON, WON_ADJUDICATION) 736 continue 737 738 # Analyzing 739 if self.engineIsInNotPlaying: 740 if parts[:4] == ["0", "0", "0", "0"]: 741 # Crafty doesn't analyze until it is out of book 742 print("book off", file=self.engine) 743 continue 744 745 match = anare.match(line) 746 if match: 747 depth, score, time, nodes, moves = match.groups() 748 749 if "mat" in score.lower() or "#" in moves: 750 # Will look either like -Mat 3 or Mat3 751 scoreval = MATE_VALUE 752 if score.startswith('-'): 753 scoreval = -scoreval 754 else: 755 scoreval = int(score) 756 757 nps = str(int(int(nodes) / (int(time) / 100))) if int(time) > 0 else "" 758 759 mvstrs = movere.findall(moves) 760 if mvstrs: 761 self.emit("analyze", [(self.board.ply, mvstrs, scoreval, depth.strip(), nps)]) 762 763 continue 764 765 # Offers draw 766 if parts[0:2] == ["offer", "draw"]: 767 self.emit("accept", Offer(DRAW_OFFER)) 768 continue 769 770 # Resigns 771 if parts[0] == "resign" or \ 772 (parts[0] == "tellics" and parts[1] == "resign"): # buggy crafty 773 774 # Previously: if "resign" in parts, 775 # however, this is too generic, since "hint", "bk", 776 # "feature option=.." and possibly other, future CECPv2 777 # commands can validly contain the word "resign" without this 778 # being an intentional resign offer. 779 780 self.emit("offer", Offer(RESIGNATION)) 781 continue 782 783 # if parts[0].lower() == "error": 784 # continue 785 786 # Tell User Error 787 if parts[0] == "tellusererror": 788 # We don't want to see our stop analyzer hack as an error message 789 if "8/8/8/8/8/8/8/8" in "".join(parts[1:]): 790 continue 791 # Create a non-modal non-blocking message dialog with the error: 792 dlg = Gtk.MessageDialog(mainwindow(), 793 flags=0, 794 type=Gtk.MessageType.WARNING, 795 buttons=Gtk.ButtonsType.CLOSE, 796 message_format=None) 797 798 # Use the engine name if already known, otherwise the defname: 799 displayname = self.name 800 if not displayname: 801 displayname = self.defname 802 803 # Compose the dialog text: 804 dlg.set_markup(GObject.markup_escape_text(_( 805 "The engine %s reports an error:") % displayname) + "\n\n" + 806 GObject.markup_escape_text(" ".join(parts[1:]))) 807 808 # handle response signal so the "Close" button works: 809 dlg.connect("response", lambda dlg, x: dlg.destroy()) 810 811 dlg.show_all() 812 continue 813 814 # Tell Somebody 815 if parts[0][:4] == "tell" and \ 816 parts[0][4:] in ("others", "all", "ics", "icsnoalias"): 817 818 log.info("Ignoring tell %s: %s" % 819 (parts[0][4:], " ".join(parts[1:]))) 820 continue 821 822 if "feature" in parts: 823 # Some engines send features after done=1, so we will iterate after done=1 too 824 done1 = False 825 # We skip parts before 'feature', as some engines give us lines like 826 # White (1) : feature setboard=1 analyze...e="GNU Chess 5.07" done=1 827 parts = parts[parts.index("feature"):] 828 for i, pair in enumerate(parts[1:]): 829 830 # As "parts" is split with no thoughs on quotes or double quotes 831 # we need to do some extra handling. 832 833 if pair.find("=") < 0: 834 continue 835 key, value = pair.split("=", 1) 836 837 if key not in self.features: 838 continue 839 840 if value.startswith('"') and value.endswith('"'): 841 value = value[1:-1] 842 843 # If our pair was unfinished, like myname="GNU, we search the 844 # rest of the pairs for a quotating mark. 845 elif value[0] == '"': 846 rest = value[1:] + " " + " ".join(parts[2 + i:]) 847 j = rest.find('"') 848 if j == -1: 849 log.warning("Missing endquotation in %s feature", 850 extra={"task": self.defname}) 851 value = rest 852 else: 853 value = rest[:j] 854 855 elif value.isdigit(): 856 value = int(value) 857 858 if key in self.supported_features: 859 print("accepted %s" % key, file=self.engine) 860 else: 861 print("rejected %s" % key, file=self.engine) 862 863 if key == "done": 864 if value == 1: 865 done1 = True 866 continue 867 elif value == 0: 868 log.info("Adds %d seconds timeout" % TIME_OUT_SECOND, 869 extra={"task": self.defname}) 870 # This'll buy you some more time 871 self.queue.put_nowait("not ready") 872 break 873 874 if key == "smp" and value == 1: 875 self.options["cores"] = {"name": "cores", 876 "type": "spin", 877 "default": 1, 878 "min": 1, 879 "max": 64} 880 elif key == "memory" and value == 1: 881 self.options["memory"] = {"name": "memory", 882 "type": "spin", 883 "default": 32, 884 "min": 1, 885 "max": 4096} 886 elif key == "option" and key != "done": 887 option = self.__parse_option(value) 888 self.options[option["name"]] = option 889 else: 890 self.features[key] = value 891 892 if key == "myname" and not self.name: 893 self.setName(value) 894 895 if done1: 896 # Start a new game before using the engine: 897 # (CECPv2 engines) 898 print("new", file=self.engine) 899 900 # We are now ready for play: 901 self.emit("readyForOptions") 902 self.emit("readyForMoves") 903 self.queue.put_nowait("ready") 904 905 # A hack to get better names in protover 1. 906 # Unfortunately it wont work for now, as we don't read any lines from 907 # protover 1 engines. When should we stop? 908 if self.protover == 1: 909 if self.defname[0] in ''.join(parts): 910 basis = self.defname[0] 911 name = ' '.join(itertools.dropwhile( 912 lambda part: basis not in part, parts)) 913 self.features['myname'] = name 914 if not self.name: 915 self.setName(name) 916 917 def __parse_option(self, option): 918 if " -check " in option: 919 name, value = option.split(" -check ") 920 return {"type": "check", "name": name, "default": bool(int(value))} 921 elif " -spin " in option: 922 name, value = option.split(" -spin ") 923 defv, minv, maxv = value.split() 924 return {"type": "spin", 925 "name": name, 926 "default": int(defv), 927 "min": int(minv), 928 "max": int(maxv)} 929 elif " -slider " in option: 930 name, value = option.split(" -slider ") 931 defv, minv, maxv = value.split() 932 return {"type": "spin", 933 "name": name, 934 "default": int(defv), 935 "min": int(minv), 936 "max": int(maxv)} 937 elif " -string " in option: 938 name, value = option.split(" -string ") 939 return {"type": "text", "name": name, "default": value} 940 elif " -file " in option: 941 name, value = option.split(" -file ") 942 return {"type": "text", "name": name, "default": value} 943 elif " -path " in option: 944 name, value = option.split(" -path ") 945 return {"type": "text", "name": name, "default": value} 946 elif " -combo " in option: 947 name, value = option.split(" -combo ") 948 choices = list(map(str.strip, value.split("///"))) 949 default = "" 950 for choice in choices: 951 if choice.startswith("*"): 952 index = choices.index(choice) 953 default = choice[1:] 954 choices[index] = default 955 break 956 return {"type": "combo", 957 "name": name, 958 "default": default, 959 "choices": choices} 960 elif " -button" in option: 961 pos = option.find(" -button") 962 return {"type": "button", "name": option[:pos]} 963 elif " -save" in option: 964 pos = option.find(" -save") 965 return {"type": "button", "name": option[:pos]} 966 elif " -reset" in option: 967 pos = option.find(" -reset") 968 return {"type": "button", "name": option[:pos]} 969 970 # Info 971 972 def canAnalyze(self): 973 assert self.ready, "Still waiting for done=1" 974 return self.features["analyze"] 975 976 def getAnalysisLines(self): 977 return 1 978 979 def minAnalysisLines(self): 980 return 1 981 982 def maxAnalysisLines(self): 983 return 1 984 985 def requestMultiPV(self, setting): 986 return 1 987 988 def __repr__(self): 989 if self.name: 990 return self.name 991 return self.features["myname"] 992