Skip to content

Commit 006fd94

Browse files
authored
Merge pull request #957 from MarkZH/xboard-opponent-information
Method for sending opponent information to engines
2 parents c06de5d + 06e3145 commit 006fd94

2 files changed

Lines changed: 184 additions & 16 deletions

File tree

chess/engine.py

Lines changed: 103 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,15 @@ class Info(enum.IntFlag):
438438
INFO_ALL = Info.ALL
439439

440440

441+
@dataclasses.dataclass
442+
class Opponent:
443+
"""Used to store information about an engine's opponent."""
444+
name: Optional[str]
445+
title: Optional[str]
446+
rating: Optional[int]
447+
is_engine: Optional[bool]
448+
449+
441450
class PovScore:
442451
"""A relative :class:`~chess.engine.Score` and the point of view."""
443452

@@ -1122,7 +1131,19 @@ async def configure(self, options: ConfigMapping) -> None:
11221131
"""
11231132

11241133
@abc.abstractmethod
1125-
async def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> PlayResult:
1134+
async def send_opponent_information(self, *, opponent: Optional[Opponent] = None, engine_rating: Optional[int] = None) -> None:
1135+
"""
1136+
Sends the engine information about its opponent. The information will
1137+
be sent after a new game is announced and before the first move. This
1138+
method should be called before the first move of a game--i.e., the
1139+
first call to :func:`chess.engine.Protocol.play()`.
1140+
1141+
:param opponent: Optional. The opponent's information.
1142+
:param engine_rating: Optional. This engine's own rating. Only used by XBoard engines.
1143+
"""
1144+
1145+
@abc.abstractmethod
1146+
async def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}, opponent: Optional[Opponent] = None) -> PlayResult:
11261147
"""
11271148
Plays a position.
11281149
@@ -1148,6 +1169,10 @@ async def play(self, board: chess.Board, limit: Limit, *, game: object = None, i
11481169
analysis. The previous configuration will be restored after the
11491170
analysis is complete. You can permanently apply a configuration
11501171
with :func:`~chess.engine.Protocol.configure()`.
1172+
:param opponent: Optional. Information about a new opponent. Information
1173+
about the original opponent will be restored once the move is
1174+
complete. New opponent information can be made permanent with
1175+
:func:`~chess.engine.Protocol.send_opponent_information()`.
11511176
"""
11521177

11531178
@typing.overload
@@ -1433,8 +1458,14 @@ def _id(self, engine: UciProtocol, arg: str) -> None:
14331458
def _isready(self) -> None:
14341459
self.send_line("isready")
14351460

1461+
def _opponent_info(self) -> None:
1462+
opponent_info = self.config.get("UCI_Opponent") or self.target_config.get("UCI_Opponent")
1463+
if opponent_info:
1464+
self.send_line(f"setoption name UCI_Opponent value {opponent_info}")
1465+
14361466
def _ucinewgame(self) -> None:
14371467
self.send_line("ucinewgame")
1468+
self._opponent_info()
14381469
self.first_game = False
14391470
self.ponderhit = False
14401471

@@ -1481,7 +1512,8 @@ def _setoption(self, name: str, value: ConfigValue) -> None:
14811512
builder.append("value")
14821513
builder.append(str(value))
14831514

1484-
self.send_line(" ".join(builder))
1515+
if name != "UCI_Opponent": # sent after ucinewgame
1516+
self.send_line(" ".join(builder))
14851517
self.config[name] = value
14861518

14871519
def _configure(self, options: ConfigMapping) -> None:
@@ -1500,6 +1532,18 @@ def start(self, engine: UciProtocol) -> None:
15001532

15011533
return await self.communicate(UciConfigureCommand)
15021534

1535+
def _opponent_configuration(self, *, opponent: Optional[Opponent] = None, engine_rating: Optional[int] = None) -> ConfigMapping:
1536+
if opponent and opponent.name and "UCI_Opponent" in self.options:
1537+
rating = opponent.rating or "none"
1538+
title = opponent.title or "none"
1539+
player_type = "computer" if opponent.is_engine else "human"
1540+
return {"UCI_Opponent": f"{title} {rating} {player_type} {opponent.name}"}
1541+
else:
1542+
return {}
1543+
1544+
async def send_opponent_information(self, *, opponent: Optional[Opponent] = None, engine_rating: Optional[int] = None) -> None:
1545+
return await self.configure(self._opponent_configuration(opponent=opponent, engine_rating=engine_rating))
1546+
15031547
def _position(self, board: chess.Board) -> None:
15041548
# Select UCI_Variant and UCI_Chess960.
15051549
uci_variant = type(board).uci_variant
@@ -1576,15 +1620,20 @@ def _go(self, limit: Limit, *, root_moves: Optional[Iterable[chess.Move]] = None
15761620
builder.append("0000")
15771621
self.send_line(" ".join(builder))
15781622

1579-
async def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> PlayResult:
1623+
async def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}, opponent: Optional[Opponent] = None) -> PlayResult:
1624+
new_options: Dict[str, ConfigValue] = {}
1625+
for name, value in options.items():
1626+
new_options[name] = value
1627+
new_options.update(self._opponent_configuration(opponent=opponent))
1628+
15801629
class UciPlayCommand(BaseCommand[UciProtocol, PlayResult]):
15811630
def __init__(self, engine: UciProtocol):
15821631
super().__init__(engine)
15831632

15841633
# May ponderhit only in the same game and with unchanged target
15851634
# options. The managed options UCI_AnalyseMode, Ponder, and
15861635
# MultiPV never change between pondering play commands.
1587-
engine.may_ponderhit = board if ponder and not engine.first_game and game == engine.game and not engine._changed_options(options) else None
1636+
engine.may_ponderhit = board if ponder and not engine.first_game and game == engine.game and not engine._changed_options(new_options) else None
15881637

15891638
def start(self, engine: UciProtocol) -> None:
15901639
self.info: InfoDict = {}
@@ -1597,16 +1646,18 @@ def start(self, engine: UciProtocol) -> None:
15971646
engine.send_line("ponderhit")
15981647
return
15991648

1600-
if "UCI_AnalyseMode" in engine.options and "UCI_AnalyseMode" not in engine.target_config and all(name.lower() != "uci_analysemode" for name in options):
1649+
if "UCI_AnalyseMode" in engine.options and "UCI_AnalyseMode" not in engine.target_config and all(name.lower() != "uci_analysemode" for name in new_options):
16011650
engine._setoption("UCI_AnalyseMode", False)
16021651
if "Ponder" in engine.options:
16031652
engine._setoption("Ponder", ponder)
16041653
if "MultiPV" in engine.options:
16051654
engine._setoption("MultiPV", engine.options["MultiPV"].default)
16061655

1607-
engine._configure(options)
1656+
new_opponent = new_options.get("UCI_Opponent") or engine.target_config.get("UCI_Opponent")
1657+
opponent_changed = new_opponent != engine.config.get("UCI_Opponent")
1658+
engine._configure(new_options)
16081659

1609-
if engine.first_game or engine.game != game:
1660+
if engine.first_game or engine.game != game or opponent_changed:
16101661
engine.game = game
16111662
engine._ucinewgame()
16121663
self.sent_isready = True
@@ -1942,6 +1993,9 @@ def __init__(self) -> None:
19421993
self.options = {
19431994
"random": Option("random", "check", False, None, None, None),
19441995
"computer": Option("computer", "check", False, None, None, None),
1996+
"name": Option("name", "string", "", None, None, None),
1997+
"engine_rating": Option("engine_rating", "spin", 0, None, None, None),
1998+
"opponent_rating": Option("opponent_rating", "spin", 0, None, None, None)
19451999
}
19462000
self.config: Dict[str, ConfigValue] = {}
19472001
self.target_config: Dict[str, ConfigValue] = {}
@@ -2047,13 +2101,13 @@ def _variant(self, variant: Optional[str]) -> None:
20472101

20482102
self.send_line(f"variant {variant}")
20492103

2050-
def _new(self, board: chess.Board, game: object, options: ConfigMapping) -> None:
2104+
def _new(self, board: chess.Board, game: object, options: ConfigMapping, opponent: Optional[Opponent] = None) -> None:
20512105
self._configure(options)
20522106

20532107
# Set up starting position.
20542108
root = board.root()
2055-
new_options = "random" in options or "computer" in options
2056-
new_game = self.first_game or self.game != game or new_options or root != self.board.root()
2109+
new_options = any(param in options for param in ("random", "computer"))
2110+
new_game = self.first_game or self.game != game or new_options or opponent or root != self.board.root()
20572111
self.game = game
20582112
self.first_game = False
20592113
if new_game:
@@ -2068,7 +2122,16 @@ def _new(self, board: chess.Board, game: object, options: ConfigMapping) -> None
20682122

20692123
if self.config.get("random"):
20702124
self.send_line("random")
2071-
if self.config.get("computer"):
2125+
2126+
opponent_name = (opponent.name if opponent else None) or self.target_config.get("name")
2127+
if opponent_name and self.features.get("name", True):
2128+
self.send_line(f"name {opponent_name}")
2129+
2130+
opponent_rating = (opponent.rating if opponent else None) or self.target_config.get("opponent_rating") or 0
2131+
if self.target_config.get("engine_rating") or opponent_rating:
2132+
self.send_line(f"rating {self.target_config.get('engine_rating') or 0} {opponent_rating}")
2133+
2134+
if (opponent and opponent.is_engine) or (self.target_config.get("computer") if self.config.get("computer") is None else self.config.get("computer")):
20722135
self.send_line("computer")
20732136

20742137
self.send_line("force")
@@ -2122,7 +2185,7 @@ def line_received(self, engine: XBoardProtocol, line: str) -> None:
21222185

21232186
return await self.communicate(XBoardPingCommand)
21242187

2125-
async def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> PlayResult:
2188+
async def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}, opponent: Optional[Opponent] = None) -> PlayResult:
21262189
if root_moves is not None:
21272190
raise EngineError("play with root_moves, but xboard supports 'include' only in analysis mode")
21282191

@@ -2134,7 +2197,7 @@ def start(self, engine: XBoardProtocol) -> None:
21342197
self.pong_after_ponder: Optional[str] = None
21352198

21362199
# Set game, position and configure.
2137-
engine._new(board, game, options)
2200+
engine._new(board, game, options, opponent)
21382201

21392202
# Limit or time control.
21402203
clock = limit.white_clock if board.turn else limit.black_clock
@@ -2368,7 +2431,7 @@ def _setoption(self, name: str, value: ConfigValue) -> None:
23682431

23692432
self.config[name] = value = option.parse(value)
23702433

2371-
if name in ["random", "computer"]:
2434+
if name in ["random", "computer", "name", "engine_rating", "opponent_rating"]:
23722435
# Applied in _new.
23732436
pass
23742437
elif name in ["memory", "cores"] or name.startswith("egtpath "):
@@ -2398,6 +2461,22 @@ def start(self, engine: XBoardProtocol) -> None:
23982461

23992462
return await self.communicate(XBoardConfigureCommand)
24002463

2464+
def _opponent_configuration(self, *, opponent: Optional[Opponent] = None, engine_rating: Optional[int] = None) -> ConfigMapping:
2465+
if opponent is None:
2466+
return {}
2467+
2468+
opponent_info: Dict[str, Union[int, bool, str]] = {"engine_rating": engine_rating or 0,
2469+
"opponent_rating": opponent.rating or 0,
2470+
"computer": opponent.is_engine or False}
2471+
2472+
if opponent.name and self.features.get("name", True):
2473+
opponent_info["name"] = f"{opponent.title or ''} {opponent.name}".strip()
2474+
2475+
return opponent_info
2476+
2477+
async def send_opponent_information(self, *, opponent: Optional[Opponent] = None, engine_rating: Optional[int] = None) -> None:
2478+
return await self.configure(self._opponent_configuration(opponent=opponent, engine_rating=engine_rating))
2479+
24012480
async def quit(self) -> None:
24022481
self.send_line("quit")
24032482
await asyncio.shield(self.returncode)
@@ -2781,16 +2860,24 @@ def configure(self, options: ConfigMapping) -> None:
27812860
future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop)
27822861
return future.result()
27832862

2863+
def send_opponent_information(self, *, opponent: Optional[Opponent] = None, engine_rating: Optional[int] = None) -> None:
2864+
with self._not_shut_down():
2865+
coro = asyncio.wait_for(
2866+
self.protocol.send_opponent_information(opponent=opponent, engine_rating=engine_rating),
2867+
self.timeout)
2868+
future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop)
2869+
return future.result()
2870+
27842871
def ping(self) -> None:
27852872
with self._not_shut_down():
27862873
coro = asyncio.wait_for(self.protocol.ping(), self.timeout)
27872874
future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop)
27882875
return future.result()
27892876

2790-
def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> PlayResult:
2877+
def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}, opponent: Optional[Opponent] = None) -> PlayResult:
27912878
with self._not_shut_down():
27922879
coro = asyncio.wait_for(
2793-
self.protocol.play(board, limit, game=game, info=info, ponder=ponder, draw_offered=draw_offered, root_moves=root_moves, options=options),
2880+
self.protocol.play(board, limit, game=game, info=info, ponder=ponder, draw_offered=draw_offered, root_moves=root_moves, options=options, opponent=opponent),
27942881
self._timeout_for(limit))
27952882
future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop)
27962883
return future.result()

test.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3271,13 +3271,18 @@ async def main():
32713271
mock.expect("uci", [
32723272
"option name Hash type spin default 16 min 1 max 33554432",
32733273
"option name Ponder type check default false",
3274+
"option name UCI_Opponent type string",
32743275
"uciok",
32753276
])
32763277
await protocol.initialize()
32773278

3279+
primary_opponent = chess.engine.Opponent("Eliza", None, 3500, True)
3280+
await protocol.send_opponent_information(opponent=primary_opponent)
3281+
32783282
# First search.
32793283
mock.expect("setoption name Ponder value true")
32803284
mock.expect("ucinewgame")
3285+
mock.expect("setoption name UCI_Opponent value none 3500 computer Eliza")
32813286
mock.expect("isready", ["readyok"])
32823287
mock.expect("position startpos")
32833288
mock.expect("go movetime 1000", ["bestmove d2d4 ponder g8f6"])
@@ -3341,6 +3346,33 @@ async def main():
33413346
mock.expect("go ponder movetime 5000")
33423347
await protocol.play(board, chess.engine.Limit(time=5), ponder=True)
33433348

3349+
# Ponderhit prevented by new opponent, which starts a new game.
3350+
board.push(chess.Move.from_uci("c4d5"))
3351+
board.push(chess.Move.from_uci("e6d5"))
3352+
mock.expect("stop", ["bestmove c1g5 ponder h7h6"])
3353+
mock.expect("ucinewgame")
3354+
mock.expect("setoption name UCI_Opponent value GM 3000 human Guy Chapman")
3355+
mock.expect("isready", ["readyok"])
3356+
mock.expect("position startpos moves d2d4 g8f6 c2c4 e7e6 b1c3 f8b4 d1c2 d7d5 c4d5 e6d5")
3357+
mock.expect("go movetime 5000", ["bestmove c1g5 ponder h7h6"])
3358+
mock.expect("position startpos moves d2d4 g8f6 c2c4 e7e6 b1c3 f8b4 d1c2 d7d5 c4d5 e6d5 c1g5 h7h6")
3359+
mock.expect("go ponder movetime 5000")
3360+
opponent = chess.engine.Opponent("Guy Chapman", "GM", 3000, False)
3361+
await protocol.play(board, chess.engine.Limit(time=5), ponder=True, opponent=opponent)
3362+
3363+
# Ponderhit prevented by restoration of previous opponent, which again starts a new game.
3364+
board.push(chess.Move.from_uci("c1g5"))
3365+
board.push(chess.Move.from_uci("h7h6"))
3366+
mock.expect("stop", ["bestmove g5h4 ponder b8c6"])
3367+
mock.expect("ucinewgame")
3368+
mock.expect("setoption name UCI_Opponent value none 3500 computer Eliza")
3369+
mock.expect("isready", ["readyok"])
3370+
mock.expect("position startpos moves d2d4 g8f6 c2c4 e7e6 b1c3 f8b4 d1c2 d7d5 c4d5 e6d5 c1g5 h7h6")
3371+
mock.expect("go movetime 5000", ["bestmove g5h4 ponder b8c6"])
3372+
mock.expect("position startpos moves d2d4 g8f6 c2c4 e7e6 b1c3 f8b4 d1c2 d7d5 c4d5 e6d5 c1g5 h7h6 g5h4 b8c6")
3373+
mock.expect("go ponder movetime 5000")
3374+
await protocol.play(board, chess.engine.Limit(time=5), ponder=True)
3375+
33443376
mock.assert_done()
33453377

33463378
asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy())
@@ -3557,6 +3589,55 @@ async def main():
35573589
asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy())
35583590
asyncio.run(main())
35593591

3592+
def test_xboard_opponent(self):
3593+
async def main():
3594+
protocol = chess.engine.XBoardProtocol()
3595+
mock = chess.engine.MockTransport(protocol)
3596+
3597+
mock.expect("xboard")
3598+
mock.expect("protover 2", ["feature ping=1 setboard=1 name=1 done=1"])
3599+
await protocol.initialize()
3600+
mock.assert_done()
3601+
3602+
limit = chess.engine.Limit(time=5)
3603+
board = chess.Board()
3604+
opponent = chess.engine.Opponent("Turk", "Mechanical", 2100, True)
3605+
await protocol.send_opponent_information(opponent=opponent, engine_rating=3600)
3606+
3607+
mock.expect("new")
3608+
mock.expect("name Mechanical Turk")
3609+
mock.expect("rating 3600 2100")
3610+
mock.expect("computer")
3611+
mock.expect("force")
3612+
mock.expect("st 5")
3613+
mock.expect("nopost")
3614+
mock.expect("easy")
3615+
mock.expect("go", ["move e2e4"])
3616+
mock.expect_ping()
3617+
result = await protocol.play(board, limit, game="game")
3618+
self.assertEqual(result.move, board.parse_san("e4"))
3619+
mock.assert_done()
3620+
3621+
new_opponent = chess.engine.Opponent("Turochamp", None, 800, True)
3622+
board.push(result.move)
3623+
mock.expect("new")
3624+
mock.expect("name Turochamp")
3625+
mock.expect("rating 3600 800")
3626+
mock.expect("computer")
3627+
mock.expect("force")
3628+
mock.expect("e2e4")
3629+
mock.expect("st 5")
3630+
mock.expect("nopost")
3631+
mock.expect("easy")
3632+
mock.expect("go", ["move e7e5"])
3633+
mock.expect_ping()
3634+
result = await protocol.play(board, limit, game="game", opponent=new_opponent)
3635+
self.assertEqual(result.move, board.parse_san("e5"))
3636+
mock.assert_done()
3637+
3638+
asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy())
3639+
asyncio.run(main())
3640+
35603641
def test_xboard_analyse(self):
35613642
async def main():
35623643
protocol = chess.engine.XBoardProtocol()

0 commit comments

Comments
 (0)