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