chess-mnemonic-protocol 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chess_mnemonic_protocol-0.1.0/PKG-INFO +84 -0
- chess_mnemonic_protocol-0.1.0/README +73 -0
- chess_mnemonic_protocol-0.1.0/chess_engine.py +423 -0
- chess_mnemonic_protocol-0.1.0/chess_mnemonic_protocol.egg-info/PKG-INFO +84 -0
- chess_mnemonic_protocol-0.1.0/chess_mnemonic_protocol.egg-info/SOURCES.txt +10 -0
- chess_mnemonic_protocol-0.1.0/chess_mnemonic_protocol.egg-info/dependency_links.txt +1 -0
- chess_mnemonic_protocol-0.1.0/chess_mnemonic_protocol.egg-info/entry_points.txt +2 -0
- chess_mnemonic_protocol-0.1.0/chess_mnemonic_protocol.egg-info/top_level.txt +2 -0
- chess_mnemonic_protocol-0.1.0/cmp.py +243 -0
- chess_mnemonic_protocol-0.1.0/pyproject.toml +25 -0
- chess_mnemonic_protocol-0.1.0/setup.cfg +4 -0
- chess_mnemonic_protocol-0.1.0/tests/test_cmp.py +172 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chess-mnemonic-protocol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chess Mnemonic Protocol tools
|
|
5
|
+
Project-URL: Repository, https://github.com/julesora/cmp
|
|
6
|
+
Project-URL: Specification, https://github.com/julesora/cmp/blob/main/spec/cmp-0001.md
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
CMP
|
|
13
|
+
===
|
|
14
|
+
|
|
15
|
+
CMP is a small text format for legal chess move sequences.
|
|
16
|
+
CMP 1 stores moves in lowercase UCI.
|
|
17
|
+
|
|
18
|
+
Quick Start
|
|
19
|
+
-----------
|
|
20
|
+
|
|
21
|
+
* Check a value: cmp check cmp1 e2e4 e7e5
|
|
22
|
+
* Normalize a value: cmp normalize CMP1 E2E4 E7E5
|
|
23
|
+
* Convert to SAN: cmp convert --to san cmp1 e2e4 e7e5
|
|
24
|
+
* Convert to PGN: cmp convert --to pgn cmp1 e2e4 e7e5
|
|
25
|
+
* List legal moves: cmp legal e2e4 e7e5
|
|
26
|
+
|
|
27
|
+
Install for development:
|
|
28
|
+
|
|
29
|
+
python -m pip install -e .
|
|
30
|
+
|
|
31
|
+
Format
|
|
32
|
+
------
|
|
33
|
+
|
|
34
|
+
A CMP 1 value starts with cmp1 followed by legal UCI moves:
|
|
35
|
+
|
|
36
|
+
cmp1 e2e4 e7e5 g1f3 b8c6
|
|
37
|
+
|
|
38
|
+
The main rules are:
|
|
39
|
+
|
|
40
|
+
* moves are lowercase UCI
|
|
41
|
+
* moves must be legal
|
|
42
|
+
* legal move lists are sorted
|
|
43
|
+
* a finished game resets to the start position
|
|
44
|
+
|
|
45
|
+
See spec/cmp-0001.md for the full format.
|
|
46
|
+
|
|
47
|
+
Python API
|
|
48
|
+
----------
|
|
49
|
+
|
|
50
|
+
import cmp
|
|
51
|
+
|
|
52
|
+
line = 'cmp1 e2e4 e7e5 g1f3'
|
|
53
|
+
|
|
54
|
+
cmp.check(line)
|
|
55
|
+
cmp.normalize(line)
|
|
56
|
+
cmp.parse_mnemonic(line)
|
|
57
|
+
cmp.legal_moves()
|
|
58
|
+
cmp.convert_mnemonic(line, 'san')
|
|
59
|
+
cmp.convert_mnemonic(line, 'pgn')
|
|
60
|
+
|
|
61
|
+
CMP text always uses UCI. SAN and PGN are output formats.
|
|
62
|
+
|
|
63
|
+
Documentation
|
|
64
|
+
-------------
|
|
65
|
+
|
|
66
|
+
* Format: spec/cmp-0001.md
|
|
67
|
+
* Test vectors: vectors.json
|
|
68
|
+
* Changes: CHANGELOG.md
|
|
69
|
+
* Contributing: CONTRIBUTING.md
|
|
70
|
+
* Security: SECURITY.md
|
|
71
|
+
|
|
72
|
+
Source
|
|
73
|
+
------
|
|
74
|
+
|
|
75
|
+
* cmp.py: parser, API and command line
|
|
76
|
+
* chess_engine.py: chess rules
|
|
77
|
+
* tests/test_cmp.py: tests
|
|
78
|
+
|
|
79
|
+
Development
|
|
80
|
+
-----------
|
|
81
|
+
|
|
82
|
+
Run the tests with:
|
|
83
|
+
|
|
84
|
+
python -m unittest discover -s tests -v
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
CMP
|
|
2
|
+
===
|
|
3
|
+
|
|
4
|
+
CMP is a small text format for legal chess move sequences.
|
|
5
|
+
CMP 1 stores moves in lowercase UCI.
|
|
6
|
+
|
|
7
|
+
Quick Start
|
|
8
|
+
-----------
|
|
9
|
+
|
|
10
|
+
* Check a value: cmp check cmp1 e2e4 e7e5
|
|
11
|
+
* Normalize a value: cmp normalize CMP1 E2E4 E7E5
|
|
12
|
+
* Convert to SAN: cmp convert --to san cmp1 e2e4 e7e5
|
|
13
|
+
* Convert to PGN: cmp convert --to pgn cmp1 e2e4 e7e5
|
|
14
|
+
* List legal moves: cmp legal e2e4 e7e5
|
|
15
|
+
|
|
16
|
+
Install for development:
|
|
17
|
+
|
|
18
|
+
python -m pip install -e .
|
|
19
|
+
|
|
20
|
+
Format
|
|
21
|
+
------
|
|
22
|
+
|
|
23
|
+
A CMP 1 value starts with cmp1 followed by legal UCI moves:
|
|
24
|
+
|
|
25
|
+
cmp1 e2e4 e7e5 g1f3 b8c6
|
|
26
|
+
|
|
27
|
+
The main rules are:
|
|
28
|
+
|
|
29
|
+
* moves are lowercase UCI
|
|
30
|
+
* moves must be legal
|
|
31
|
+
* legal move lists are sorted
|
|
32
|
+
* a finished game resets to the start position
|
|
33
|
+
|
|
34
|
+
See spec/cmp-0001.md for the full format.
|
|
35
|
+
|
|
36
|
+
Python API
|
|
37
|
+
----------
|
|
38
|
+
|
|
39
|
+
import cmp
|
|
40
|
+
|
|
41
|
+
line = 'cmp1 e2e4 e7e5 g1f3'
|
|
42
|
+
|
|
43
|
+
cmp.check(line)
|
|
44
|
+
cmp.normalize(line)
|
|
45
|
+
cmp.parse_mnemonic(line)
|
|
46
|
+
cmp.legal_moves()
|
|
47
|
+
cmp.convert_mnemonic(line, 'san')
|
|
48
|
+
cmp.convert_mnemonic(line, 'pgn')
|
|
49
|
+
|
|
50
|
+
CMP text always uses UCI. SAN and PGN are output formats.
|
|
51
|
+
|
|
52
|
+
Documentation
|
|
53
|
+
-------------
|
|
54
|
+
|
|
55
|
+
* Format: spec/cmp-0001.md
|
|
56
|
+
* Test vectors: vectors.json
|
|
57
|
+
* Changes: CHANGELOG.md
|
|
58
|
+
* Contributing: CONTRIBUTING.md
|
|
59
|
+
* Security: SECURITY.md
|
|
60
|
+
|
|
61
|
+
Source
|
|
62
|
+
------
|
|
63
|
+
|
|
64
|
+
* cmp.py: parser, API and command line
|
|
65
|
+
* chess_engine.py: chess rules
|
|
66
|
+
* tests/test_cmp.py: tests
|
|
67
|
+
|
|
68
|
+
Development
|
|
69
|
+
-----------
|
|
70
|
+
|
|
71
|
+
Run the tests with:
|
|
72
|
+
|
|
73
|
+
python -m unittest discover -s tests -v
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
FILES = 'abcdefgh'
|
|
2
|
+
PROMOTIONS = 'qrbn'
|
|
3
|
+
KNIGHT_STEPS = ((1,2),(2,1),(2,-1),(1,-2),(-1,-2),(-2,-1),(-2,1),(-1,2))
|
|
4
|
+
BISHOP_DIRS = ((1,1),(1,-1),(-1,1),(-1,-1))
|
|
5
|
+
ROOK_DIRS = ((1,0),(-1,0),(0,1),(0,-1))
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _sq(file, rank):
|
|
9
|
+
return rank * 8 + file
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _file(square):
|
|
13
|
+
return square & 7
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _rank(square):
|
|
17
|
+
return square >> 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _inside(file, rank):
|
|
21
|
+
return 0 <= file < 8 and 0 <= rank < 8
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _other(color):
|
|
25
|
+
return 'b' if color == 'w' else 'w'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _color(piece):
|
|
29
|
+
if not piece:
|
|
30
|
+
return None
|
|
31
|
+
return 'w' if piece.isupper() else 'b'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _type(piece):
|
|
35
|
+
if not piece:
|
|
36
|
+
return None
|
|
37
|
+
return piece.lower()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _name(square):
|
|
41
|
+
return '%s%d' % (FILES[_file(square)], _rank(square) + 1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def initial_state():
|
|
45
|
+
board = [None] * 64
|
|
46
|
+
back = 'rnbqkbnr'
|
|
47
|
+
|
|
48
|
+
for file in range(8):
|
|
49
|
+
board[_sq(file, 0)] = back[file].upper()
|
|
50
|
+
board[_sq(file, 1)] = 'P'
|
|
51
|
+
board[_sq(file, 6)] = 'p'
|
|
52
|
+
board[_sq(file, 7)] = back[file]
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
'board': board,
|
|
56
|
+
'turn': 'w',
|
|
57
|
+
'castling': set('KQkq'),
|
|
58
|
+
'ep': None,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _copy(state):
|
|
63
|
+
return {
|
|
64
|
+
'board': state['board'].copy(),
|
|
65
|
+
'turn': state['turn'],
|
|
66
|
+
'castling': state['castling'].copy(),
|
|
67
|
+
'ep': state['ep'],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _attacked(state, target, color):
|
|
72
|
+
board = state['board']
|
|
73
|
+
target_file = _file(target)
|
|
74
|
+
target_rank = _rank(target)
|
|
75
|
+
pawn_rank = target_rank + (-1 if color == 'w' else 1)
|
|
76
|
+
|
|
77
|
+
for df in (-1, 1):
|
|
78
|
+
file = target_file + df
|
|
79
|
+
if _inside(file, pawn_rank):
|
|
80
|
+
piece = board[_sq(file, pawn_rank)]
|
|
81
|
+
if piece and _color(piece) == color and _type(piece) == 'p':
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
for df, dr in KNIGHT_STEPS:
|
|
85
|
+
file = target_file + df
|
|
86
|
+
rank = target_rank + dr
|
|
87
|
+
if _inside(file, rank):
|
|
88
|
+
piece = board[_sq(file, rank)]
|
|
89
|
+
if piece and _color(piece) == color and _type(piece) == 'n':
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
for dirs, pieces in ((BISHOP_DIRS, 'bq'), (ROOK_DIRS, 'rq')):
|
|
93
|
+
for df, dr in dirs:
|
|
94
|
+
file = target_file + df
|
|
95
|
+
rank = target_rank + dr
|
|
96
|
+
while _inside(file, rank):
|
|
97
|
+
piece = board[_sq(file, rank)]
|
|
98
|
+
if piece:
|
|
99
|
+
if _color(piece) == color and _type(piece) in pieces:
|
|
100
|
+
return True
|
|
101
|
+
break
|
|
102
|
+
file += df
|
|
103
|
+
rank += dr
|
|
104
|
+
|
|
105
|
+
for df in (-1, 0, 1):
|
|
106
|
+
for dr in (-1, 0, 1):
|
|
107
|
+
if not (df or dr):
|
|
108
|
+
continue
|
|
109
|
+
file = target_file + df
|
|
110
|
+
rank = target_rank + dr
|
|
111
|
+
if _inside(file, rank):
|
|
112
|
+
piece = board[_sq(file, rank)]
|
|
113
|
+
if piece and _color(piece) == color and _type(piece) == 'k':
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _in_check(state, color):
|
|
120
|
+
king = 'K' if color == 'w' else 'k'
|
|
121
|
+
try:
|
|
122
|
+
square = state['board'].index(king)
|
|
123
|
+
except ValueError:
|
|
124
|
+
return True
|
|
125
|
+
return _attacked(state, square, _other(color))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _castling_moves(state, start, add):
|
|
129
|
+
board = state['board']
|
|
130
|
+
turn = state['turn']
|
|
131
|
+
rank = 0 if turn == 'w' else 7
|
|
132
|
+
|
|
133
|
+
if start != _sq(4, rank) or _in_check(state, turn):
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
enemy = _other(turn)
|
|
137
|
+
rook = 'R' if turn == 'w' else 'r'
|
|
138
|
+
king_side = 'K' if turn == 'w' else 'k'
|
|
139
|
+
queen_side = 'Q' if turn == 'w' else 'q'
|
|
140
|
+
|
|
141
|
+
if king_side in state['castling'] and board[_sq(7, rank)] == rook:
|
|
142
|
+
if not board[_sq(5, rank)] and not board[_sq(6, rank)]:
|
|
143
|
+
safe = not _attacked(state, _sq(5, rank), enemy)
|
|
144
|
+
safe = safe and not _attacked(state, _sq(6, rank), enemy)
|
|
145
|
+
if safe:
|
|
146
|
+
add(start, _sq(6, rank))
|
|
147
|
+
|
|
148
|
+
if queen_side in state['castling'] and board[_sq(0, rank)] == rook:
|
|
149
|
+
clear = not board[_sq(1, rank)]
|
|
150
|
+
clear = clear and not board[_sq(2, rank)]
|
|
151
|
+
clear = clear and not board[_sq(3, rank)]
|
|
152
|
+
if clear:
|
|
153
|
+
safe = not _attacked(state, _sq(3, rank), enemy)
|
|
154
|
+
safe = safe and not _attacked(state, _sq(2, rank), enemy)
|
|
155
|
+
if safe:
|
|
156
|
+
add(start, _sq(2, rank))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _pseudo_moves(state):
|
|
160
|
+
moves = []
|
|
161
|
+
board = state['board']
|
|
162
|
+
turn = state['turn']
|
|
163
|
+
|
|
164
|
+
def add(start, end, promotion=''):
|
|
165
|
+
moves.append((start, end, promotion))
|
|
166
|
+
|
|
167
|
+
for start, piece in enumerate(board):
|
|
168
|
+
if not piece or _color(piece) != turn:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
kind = _type(piece)
|
|
172
|
+
file = _file(start)
|
|
173
|
+
rank = _rank(start)
|
|
174
|
+
|
|
175
|
+
if kind == 'p':
|
|
176
|
+
step = 1 if turn == 'w' else -1
|
|
177
|
+
home = 1 if turn == 'w' else 6
|
|
178
|
+
promo = 7 if turn == 'w' else 0
|
|
179
|
+
one = rank + step
|
|
180
|
+
|
|
181
|
+
if _inside(file, one) and not board[_sq(file, one)]:
|
|
182
|
+
end = _sq(file, one)
|
|
183
|
+
if one == promo:
|
|
184
|
+
for promotion in PROMOTIONS:
|
|
185
|
+
add(start, end, promotion)
|
|
186
|
+
else:
|
|
187
|
+
add(start, end)
|
|
188
|
+
|
|
189
|
+
two = rank + (2 * step)
|
|
190
|
+
if rank == home and not board[_sq(file, two)]:
|
|
191
|
+
add(start, _sq(file, two))
|
|
192
|
+
|
|
193
|
+
for df in (-1, 1):
|
|
194
|
+
capture_file = file + df
|
|
195
|
+
capture_rank = rank + step
|
|
196
|
+
if not _inside(capture_file, capture_rank):
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
end = _sq(capture_file, capture_rank)
|
|
200
|
+
target = board[end]
|
|
201
|
+
capture = target and _color(target) != turn
|
|
202
|
+
if capture or state['ep'] == end:
|
|
203
|
+
if capture_rank == promo:
|
|
204
|
+
for promotion in PROMOTIONS:
|
|
205
|
+
add(start, end, promotion)
|
|
206
|
+
else:
|
|
207
|
+
add(start, end)
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
if kind == 'n':
|
|
211
|
+
for df, dr in KNIGHT_STEPS:
|
|
212
|
+
next_file = file + df
|
|
213
|
+
next_rank = rank + dr
|
|
214
|
+
if not _inside(next_file, next_rank):
|
|
215
|
+
continue
|
|
216
|
+
end = _sq(next_file, next_rank)
|
|
217
|
+
if not board[end] or _color(board[end]) != turn:
|
|
218
|
+
add(start, end)
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if kind in 'brq':
|
|
222
|
+
if kind == 'b':
|
|
223
|
+
dirs = BISHOP_DIRS
|
|
224
|
+
elif kind == 'r':
|
|
225
|
+
dirs = ROOK_DIRS
|
|
226
|
+
else:
|
|
227
|
+
dirs = BISHOP_DIRS + ROOK_DIRS
|
|
228
|
+
|
|
229
|
+
for df, dr in dirs:
|
|
230
|
+
next_file = file + df
|
|
231
|
+
next_rank = rank + dr
|
|
232
|
+
while _inside(next_file, next_rank):
|
|
233
|
+
end = _sq(next_file, next_rank)
|
|
234
|
+
if not board[end]:
|
|
235
|
+
add(start, end)
|
|
236
|
+
else:
|
|
237
|
+
if _color(board[end]) != turn:
|
|
238
|
+
add(start, end)
|
|
239
|
+
break
|
|
240
|
+
next_file += df
|
|
241
|
+
next_rank += dr
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
if kind == 'k':
|
|
245
|
+
for df in (-1, 0, 1):
|
|
246
|
+
for dr in (-1, 0, 1):
|
|
247
|
+
if not (df or dr):
|
|
248
|
+
continue
|
|
249
|
+
next_file = file + df
|
|
250
|
+
next_rank = rank + dr
|
|
251
|
+
if _inside(next_file, next_rank):
|
|
252
|
+
end = _sq(next_file, next_rank)
|
|
253
|
+
if not board[end] or _color(board[end]) != turn:
|
|
254
|
+
add(start, end)
|
|
255
|
+
_castling_moves(state, start, add)
|
|
256
|
+
|
|
257
|
+
return moves
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _apply(state, move):
|
|
261
|
+
start, end, promotion = move
|
|
262
|
+
new_state = _copy(state)
|
|
263
|
+
board = new_state['board']
|
|
264
|
+
piece = board[start]
|
|
265
|
+
color = _color(piece)
|
|
266
|
+
kind = _type(piece)
|
|
267
|
+
captured = board[end]
|
|
268
|
+
start_name = _name(start)
|
|
269
|
+
end_name = _name(end)
|
|
270
|
+
|
|
271
|
+
board[start] = None
|
|
272
|
+
|
|
273
|
+
if kind == 'p' and state['ep'] == end and not captured:
|
|
274
|
+
if _file(start) != _file(end):
|
|
275
|
+
offset = -8 if color == 'w' else 8
|
|
276
|
+
board[end + offset] = None
|
|
277
|
+
|
|
278
|
+
if promotion:
|
|
279
|
+
piece = promotion.upper() if color == 'w' else promotion
|
|
280
|
+
|
|
281
|
+
board[end] = piece
|
|
282
|
+
|
|
283
|
+
if kind == 'k' and abs(_file(end) - _file(start)) == 2:
|
|
284
|
+
rank = _rank(start)
|
|
285
|
+
king_side = _file(end) == 6
|
|
286
|
+
rook_from = _sq(7 if king_side else 0, rank)
|
|
287
|
+
rook_to = _sq(5 if king_side else 3, rank)
|
|
288
|
+
board[rook_to] = board[rook_from]
|
|
289
|
+
board[rook_from] = None
|
|
290
|
+
|
|
291
|
+
if kind == 'k':
|
|
292
|
+
new_state['castling'].discard('K' if color == 'w' else 'k')
|
|
293
|
+
new_state['castling'].discard('Q' if color == 'w' else 'q')
|
|
294
|
+
|
|
295
|
+
rights = (('h1','K'),('a1','Q'),('h8','k'),('a8','q'))
|
|
296
|
+
for square, right in rights:
|
|
297
|
+
if start_name == square or end_name == square:
|
|
298
|
+
new_state['castling'].discard(right)
|
|
299
|
+
|
|
300
|
+
new_state['ep'] = None
|
|
301
|
+
if kind == 'p' and abs(_rank(end) - _rank(start)) == 2:
|
|
302
|
+
new_state['ep'] = (start + end) >> 1
|
|
303
|
+
|
|
304
|
+
new_state['turn'] = _other(state['turn'])
|
|
305
|
+
return new_state
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _uci(move):
|
|
309
|
+
start, end, promotion = move
|
|
310
|
+
return _name(start) + _name(end) + promotion
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _legal_objects(state):
|
|
314
|
+
color = state['turn']
|
|
315
|
+
moves = []
|
|
316
|
+
|
|
317
|
+
for move in _pseudo_moves(state):
|
|
318
|
+
if not _in_check(_apply(state, move), color):
|
|
319
|
+
moves.append(move)
|
|
320
|
+
|
|
321
|
+
return moves
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _find_move(state, uci):
|
|
325
|
+
uci = str(uci).strip().lower()
|
|
326
|
+
for move in _legal_objects(state):
|
|
327
|
+
if _uci(move) == uci:
|
|
328
|
+
return move
|
|
329
|
+
raise ValueError('illegal move: %s' % uci)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def legal_moves(state):
|
|
333
|
+
moves = [_uci(move) for move in _legal_objects(state)]
|
|
334
|
+
moves.sort()
|
|
335
|
+
return moves
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _is_capture(state, move):
|
|
339
|
+
start, end, unused = move
|
|
340
|
+
piece = state['board'][start]
|
|
341
|
+
|
|
342
|
+
if state['board'][end]:
|
|
343
|
+
return True
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
_type(piece) == 'p'
|
|
347
|
+
and state['ep'] == end
|
|
348
|
+
and _file(start) != _file(end)
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _san_prefix(state, move, legal):
|
|
353
|
+
start, end, unused = move
|
|
354
|
+
board = state['board']
|
|
355
|
+
kind = _type(board[start])
|
|
356
|
+
|
|
357
|
+
if kind == 'p':
|
|
358
|
+
return FILES[_file(start)] if _is_capture(state, move) else ''
|
|
359
|
+
|
|
360
|
+
prefix = kind.upper()
|
|
361
|
+
others = []
|
|
362
|
+
for candidate in legal:
|
|
363
|
+
if candidate == move or candidate[1] != end:
|
|
364
|
+
continue
|
|
365
|
+
if _type(board[candidate[0]]) == kind:
|
|
366
|
+
others.append(candidate)
|
|
367
|
+
|
|
368
|
+
if not others:
|
|
369
|
+
return prefix
|
|
370
|
+
|
|
371
|
+
same_file = any(_file(other[0]) == _file(start) for other in others)
|
|
372
|
+
same_rank = any(_rank(other[0]) == _rank(start) for other in others)
|
|
373
|
+
|
|
374
|
+
if not same_file:
|
|
375
|
+
return prefix + FILES[_file(start)]
|
|
376
|
+
if not same_rank:
|
|
377
|
+
return prefix + str(_rank(start) + 1)
|
|
378
|
+
return prefix + _name(start)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def san(state, uci):
|
|
382
|
+
legal = _legal_objects(state)
|
|
383
|
+
uci = str(uci).strip().lower()
|
|
384
|
+
move = None
|
|
385
|
+
|
|
386
|
+
for candidate in legal:
|
|
387
|
+
if _uci(candidate) == uci:
|
|
388
|
+
move = candidate
|
|
389
|
+
break
|
|
390
|
+
|
|
391
|
+
if move is None:
|
|
392
|
+
raise ValueError('illegal move: %s' % uci)
|
|
393
|
+
|
|
394
|
+
start, end, promotion = move
|
|
395
|
+
kind = _type(state['board'][start])
|
|
396
|
+
|
|
397
|
+
if kind == 'k' and abs(_file(end) - _file(start)) == 2:
|
|
398
|
+
text = 'O-O' if _file(end) == 6 else 'O-O-O'
|
|
399
|
+
else:
|
|
400
|
+
text = _san_prefix(state, move, legal)
|
|
401
|
+
if _is_capture(state, move):
|
|
402
|
+
text += 'x'
|
|
403
|
+
text += _name(end)
|
|
404
|
+
if promotion:
|
|
405
|
+
text += '=' + promotion.upper()
|
|
406
|
+
|
|
407
|
+
next_state = _apply(state, move)
|
|
408
|
+
if _in_check(next_state, next_state['turn']):
|
|
409
|
+
text += '#' if not _legal_objects(next_state) else '+'
|
|
410
|
+
|
|
411
|
+
return text
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def play(state, uci):
|
|
415
|
+
return _apply(state, _find_move(state, uci))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def result(state):
|
|
419
|
+
if _legal_objects(state):
|
|
420
|
+
return None
|
|
421
|
+
if not _in_check(state, state['turn']):
|
|
422
|
+
return '1/2-1/2'
|
|
423
|
+
return '0-1' if state['turn'] == 'w' else '1-0'
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chess-mnemonic-protocol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chess Mnemonic Protocol tools
|
|
5
|
+
Project-URL: Repository, https://github.com/julesora/cmp
|
|
6
|
+
Project-URL: Specification, https://github.com/julesora/cmp/blob/main/spec/cmp-0001.md
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
CMP
|
|
13
|
+
===
|
|
14
|
+
|
|
15
|
+
CMP is a small text format for legal chess move sequences.
|
|
16
|
+
CMP 1 stores moves in lowercase UCI.
|
|
17
|
+
|
|
18
|
+
Quick Start
|
|
19
|
+
-----------
|
|
20
|
+
|
|
21
|
+
* Check a value: cmp check cmp1 e2e4 e7e5
|
|
22
|
+
* Normalize a value: cmp normalize CMP1 E2E4 E7E5
|
|
23
|
+
* Convert to SAN: cmp convert --to san cmp1 e2e4 e7e5
|
|
24
|
+
* Convert to PGN: cmp convert --to pgn cmp1 e2e4 e7e5
|
|
25
|
+
* List legal moves: cmp legal e2e4 e7e5
|
|
26
|
+
|
|
27
|
+
Install for development:
|
|
28
|
+
|
|
29
|
+
python -m pip install -e .
|
|
30
|
+
|
|
31
|
+
Format
|
|
32
|
+
------
|
|
33
|
+
|
|
34
|
+
A CMP 1 value starts with cmp1 followed by legal UCI moves:
|
|
35
|
+
|
|
36
|
+
cmp1 e2e4 e7e5 g1f3 b8c6
|
|
37
|
+
|
|
38
|
+
The main rules are:
|
|
39
|
+
|
|
40
|
+
* moves are lowercase UCI
|
|
41
|
+
* moves must be legal
|
|
42
|
+
* legal move lists are sorted
|
|
43
|
+
* a finished game resets to the start position
|
|
44
|
+
|
|
45
|
+
See spec/cmp-0001.md for the full format.
|
|
46
|
+
|
|
47
|
+
Python API
|
|
48
|
+
----------
|
|
49
|
+
|
|
50
|
+
import cmp
|
|
51
|
+
|
|
52
|
+
line = 'cmp1 e2e4 e7e5 g1f3'
|
|
53
|
+
|
|
54
|
+
cmp.check(line)
|
|
55
|
+
cmp.normalize(line)
|
|
56
|
+
cmp.parse_mnemonic(line)
|
|
57
|
+
cmp.legal_moves()
|
|
58
|
+
cmp.convert_mnemonic(line, 'san')
|
|
59
|
+
cmp.convert_mnemonic(line, 'pgn')
|
|
60
|
+
|
|
61
|
+
CMP text always uses UCI. SAN and PGN are output formats.
|
|
62
|
+
|
|
63
|
+
Documentation
|
|
64
|
+
-------------
|
|
65
|
+
|
|
66
|
+
* Format: spec/cmp-0001.md
|
|
67
|
+
* Test vectors: vectors.json
|
|
68
|
+
* Changes: CHANGELOG.md
|
|
69
|
+
* Contributing: CONTRIBUTING.md
|
|
70
|
+
* Security: SECURITY.md
|
|
71
|
+
|
|
72
|
+
Source
|
|
73
|
+
------
|
|
74
|
+
|
|
75
|
+
* cmp.py: parser, API and command line
|
|
76
|
+
* chess_engine.py: chess rules
|
|
77
|
+
* tests/test_cmp.py: tests
|
|
78
|
+
|
|
79
|
+
Development
|
|
80
|
+
-----------
|
|
81
|
+
|
|
82
|
+
Run the tests with:
|
|
83
|
+
|
|
84
|
+
python -m unittest discover -s tests -v
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README
|
|
2
|
+
chess_engine.py
|
|
3
|
+
cmp.py
|
|
4
|
+
pyproject.toml
|
|
5
|
+
chess_mnemonic_protocol.egg-info/PKG-INFO
|
|
6
|
+
chess_mnemonic_protocol.egg-info/SOURCES.txt
|
|
7
|
+
chess_mnemonic_protocol.egg-info/dependency_links.txt
|
|
8
|
+
chess_mnemonic_protocol.egg-info/entry_points.txt
|
|
9
|
+
chess_mnemonic_protocol.egg-info/top_level.txt
|
|
10
|
+
tests/test_cmp.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from chess_engine import initial_state
|
|
6
|
+
from chess_engine import legal_moves as chess_moves
|
|
7
|
+
from chess_engine import play
|
|
8
|
+
from chess_engine import result as chess_result
|
|
9
|
+
from chess_engine import san as chess_san
|
|
10
|
+
|
|
11
|
+
VERSION = 'cmp1'
|
|
12
|
+
PROTOCOL_VERSION = VERSION
|
|
13
|
+
__version__ = '0.1.0'
|
|
14
|
+
|
|
15
|
+
UCI_RE = re.compile(r'^[a-h][1-8][a-h][1-8][qrbn]?$')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CMPError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidMnemonic(CMPError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _split(mnemonic):
|
|
27
|
+
words = str(mnemonic).strip().lower().split()
|
|
28
|
+
if not words or words[0] != VERSION:
|
|
29
|
+
raise InvalidMnemonic('mnemonic must start with %s' % VERSION)
|
|
30
|
+
if len(words) == 1:
|
|
31
|
+
raise InvalidMnemonic('mnemonic has no moves')
|
|
32
|
+
return words[1:]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_move(move, number):
|
|
36
|
+
if not UCI_RE.match(move):
|
|
37
|
+
raise InvalidMnemonic(
|
|
38
|
+
'move %d is not canonical UCI: %s' % (number, move)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _next(state, move, number):
|
|
43
|
+
_check_move(move, number)
|
|
44
|
+
|
|
45
|
+
if not chess_moves(state):
|
|
46
|
+
state = initial_state()
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
return play(state, move)
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise InvalidMnemonic('illegal move %d (%s)' % (number, move))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _play_moves(moves):
|
|
55
|
+
state = initial_state()
|
|
56
|
+
for number, move in enumerate(moves, 1):
|
|
57
|
+
state = _next(state, move, number)
|
|
58
|
+
return state
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_mnemonic(mnemonic):
|
|
62
|
+
moves = _split(mnemonic)
|
|
63
|
+
_play_moves(moves)
|
|
64
|
+
return tuple(moves)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize(mnemonic):
|
|
68
|
+
return VERSION + ' ' + ' '.join(parse_mnemonic(mnemonic))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check(mnemonic):
|
|
72
|
+
try:
|
|
73
|
+
parse_mnemonic(mnemonic)
|
|
74
|
+
return True
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _moves(value):
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
words = value.strip().lower().split()
|
|
82
|
+
if words and words[0] == VERSION:
|
|
83
|
+
return list(parse_mnemonic(value))
|
|
84
|
+
return words
|
|
85
|
+
return [str(move).strip().lower() for move in value]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def legal_moves(moves=()):
|
|
89
|
+
moves = _moves(moves)
|
|
90
|
+
state = initial_state()
|
|
91
|
+
|
|
92
|
+
for number, move in enumerate(moves, 1):
|
|
93
|
+
state = _next(state, move, number)
|
|
94
|
+
|
|
95
|
+
return chess_moves(state)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _san_games(moves):
|
|
99
|
+
games = []
|
|
100
|
+
game = []
|
|
101
|
+
state = initial_state()
|
|
102
|
+
|
|
103
|
+
for move in moves:
|
|
104
|
+
if not chess_moves(state):
|
|
105
|
+
games.append((game, chess_result(state)))
|
|
106
|
+
game = []
|
|
107
|
+
state = initial_state()
|
|
108
|
+
|
|
109
|
+
game.append(chess_san(state, move))
|
|
110
|
+
state = play(state, move)
|
|
111
|
+
|
|
112
|
+
games.append((game, chess_result(state)))
|
|
113
|
+
return games
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _san_text(moves):
|
|
117
|
+
words = []
|
|
118
|
+
|
|
119
|
+
for number, move in enumerate(moves):
|
|
120
|
+
if number % 2 == 0:
|
|
121
|
+
words.append('%d. %s' % ((number // 2) + 1, move))
|
|
122
|
+
else:
|
|
123
|
+
words.append(move)
|
|
124
|
+
|
|
125
|
+
return ' '.join(words)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _pgn(game, result, number):
|
|
129
|
+
result = result or '*'
|
|
130
|
+
headers = [
|
|
131
|
+
'[Event "CMP-1"]',
|
|
132
|
+
'[Site "?"]',
|
|
133
|
+
'[Date "????.??.??"]',
|
|
134
|
+
'[Round "%d"]' % number,
|
|
135
|
+
'[White "?"]',
|
|
136
|
+
'[Black "?"]',
|
|
137
|
+
'[Result "%s"]' % result,
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
moves = _san_text(game)
|
|
141
|
+
if moves:
|
|
142
|
+
moves += ' '
|
|
143
|
+
moves += result
|
|
144
|
+
|
|
145
|
+
return '\n'.join(headers) + '\n\n' + moves
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def convert_mnemonic(mnemonic, format='pgn'):
|
|
149
|
+
moves = parse_mnemonic(mnemonic)
|
|
150
|
+
format = str(format).strip().lower()
|
|
151
|
+
|
|
152
|
+
if format == 'uci':
|
|
153
|
+
return ' '.join(moves)
|
|
154
|
+
|
|
155
|
+
games = _san_games(moves)
|
|
156
|
+
|
|
157
|
+
if format == 'san':
|
|
158
|
+
return '\n'.join(_san_text(game) for game, result in games)
|
|
159
|
+
|
|
160
|
+
if format == 'pgn':
|
|
161
|
+
blocks = []
|
|
162
|
+
for number, item in enumerate(games, 1):
|
|
163
|
+
game, result = item
|
|
164
|
+
blocks.append(_pgn(game, result, number))
|
|
165
|
+
return '\n\n'.join(blocks)
|
|
166
|
+
|
|
167
|
+
raise ValueError('format must be uci, san, or pgn')
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parser():
|
|
171
|
+
parser = argparse.ArgumentParser(prog='cmp')
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
'--version',
|
|
174
|
+
action='version',
|
|
175
|
+
version='cmp %s (protocol %s)' % (__version__, VERSION),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
commands = parser.add_subparsers(dest='command', required=True)
|
|
179
|
+
|
|
180
|
+
command = commands.add_parser('check', help='validate a mnemonic')
|
|
181
|
+
command.add_argument('mnemonic', nargs='+')
|
|
182
|
+
|
|
183
|
+
command = commands.add_parser('normalize', help='normalize a mnemonic')
|
|
184
|
+
command.add_argument('mnemonic', nargs='+')
|
|
185
|
+
|
|
186
|
+
command = commands.add_parser('convert', help='convert chess notation')
|
|
187
|
+
command.add_argument('--to', choices=('uci', 'san', 'pgn'), default='pgn')
|
|
188
|
+
command.add_argument('mnemonic', nargs='+')
|
|
189
|
+
|
|
190
|
+
command = commands.add_parser('legal', help='list legal moves')
|
|
191
|
+
command.add_argument('moves', nargs='*')
|
|
192
|
+
|
|
193
|
+
return parser
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _text(words):
|
|
197
|
+
return ' '.join(words)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main(argv=None):
|
|
201
|
+
args = _parser().parse_args(argv)
|
|
202
|
+
|
|
203
|
+
if args.command == 'check':
|
|
204
|
+
try:
|
|
205
|
+
parse_mnemonic(_text(args.mnemonic))
|
|
206
|
+
except InvalidMnemonic as error:
|
|
207
|
+
print('invalid: %s' % error, file=sys.stderr)
|
|
208
|
+
return 1
|
|
209
|
+
print('valid')
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
if args.command == 'normalize':
|
|
213
|
+
print(normalize(_text(args.mnemonic)))
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
if args.command == 'convert':
|
|
217
|
+
print(convert_mnemonic(_text(args.mnemonic), args.to))
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
if args.command == 'legal':
|
|
221
|
+
print(' '.join(legal_moves(args.moves)))
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
return 2
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = [
|
|
228
|
+
'CMPError',
|
|
229
|
+
'InvalidMnemonic',
|
|
230
|
+
'PROTOCOL_VERSION',
|
|
231
|
+
'VERSION',
|
|
232
|
+
'__version__',
|
|
233
|
+
'check',
|
|
234
|
+
'convert_mnemonic',
|
|
235
|
+
'legal_moves',
|
|
236
|
+
'main',
|
|
237
|
+
'normalize',
|
|
238
|
+
'parse_mnemonic',
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == '__main__':
|
|
243
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chess-mnemonic-protocol"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Chess Mnemonic Protocol tools"
|
|
9
|
+
readme = {file = "README", content-type = "text/markdown"}
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
dependencies = []
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Repository = "https://github.com/julesora/cmp"
|
|
19
|
+
Specification = "https://github.com/julesora/cmp/blob/main/spec/cmp-0001.md"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
cmp = "cmp:main"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
py-modules = ["cmp", "chess_engine"]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import cmp
|
|
8
|
+
from chess_engine import initial_state
|
|
9
|
+
from chess_engine import legal_moves as engine_legal_moves
|
|
10
|
+
from chess_engine import play
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CMPTests(unittest.TestCase):
|
|
17
|
+
def test_chess_starts_with_20_moves(self):
|
|
18
|
+
moves = cmp.legal_moves()
|
|
19
|
+
self.assertEqual(len(moves), 20)
|
|
20
|
+
self.assertEqual(moves, sorted(moves))
|
|
21
|
+
|
|
22
|
+
def test_normalize(self):
|
|
23
|
+
self.assertEqual(
|
|
24
|
+
cmp.normalize(' CMP1 E2E4 E7E5 '),
|
|
25
|
+
'cmp1 e2e4 e7e5',
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def test_check_returns_boolean(self):
|
|
29
|
+
self.assertTrue(cmp.check('cmp1 e2e4 e7e5'))
|
|
30
|
+
self.assertFalse(cmp.check('cmp1 e2e5'))
|
|
31
|
+
self.assertFalse(cmp.check('e2e4'))
|
|
32
|
+
|
|
33
|
+
def test_parse_returns_canonical_moves(self):
|
|
34
|
+
self.assertEqual(
|
|
35
|
+
cmp.parse_mnemonic('CMP1 E2E4 E7E5'),
|
|
36
|
+
('e2e4', 'e7e5'),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def test_invalid_error_contains_move_index(self):
|
|
40
|
+
with self.assertRaisesRegex(cmp.InvalidMnemonic, r'move 2'):
|
|
41
|
+
cmp.parse_mnemonic('cmp1 e2e4 d2d4')
|
|
42
|
+
|
|
43
|
+
def test_convert_uci(self):
|
|
44
|
+
line = 'cmp1 e2e4 e7e5 g1f3 b8c6 f1b5'
|
|
45
|
+
self.assertEqual(
|
|
46
|
+
cmp.convert_mnemonic(line, 'uci'),
|
|
47
|
+
'e2e4 e7e5 g1f3 b8c6 f1b5',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def test_convert_san(self):
|
|
51
|
+
line = 'cmp1 e2e4 e7e5 g1f3 b8c6 f1b5'
|
|
52
|
+
self.assertEqual(
|
|
53
|
+
cmp.convert_mnemonic(line, 'san'),
|
|
54
|
+
'1. e4 e5 2. Nf3 Nc6 3. Bb5',
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def test_convert_pgn(self):
|
|
58
|
+
line = 'cmp1 e2e4 e7e5 g1f3 b8c6 f1b5'
|
|
59
|
+
pgn = cmp.convert_mnemonic(line, 'pgn')
|
|
60
|
+
self.assertIn('[Event "CMP-1"]', pgn)
|
|
61
|
+
self.assertIn('[Result "*"]', pgn)
|
|
62
|
+
self.assertTrue(pgn.endswith('1. e4 e5 2. Nf3 Nc6 3. Bb5 *'))
|
|
63
|
+
|
|
64
|
+
def test_checkmate_pgn_result(self):
|
|
65
|
+
line = 'cmp1 e2e4 e7e5 d1h5 b8c6 f1c4 g8f6 h5f7'
|
|
66
|
+
pgn = cmp.convert_mnemonic(line, 'pgn')
|
|
67
|
+
self.assertIn('[Result "1-0"]', pgn)
|
|
68
|
+
self.assertTrue(pgn.endswith('Qxf7# 1-0'))
|
|
69
|
+
|
|
70
|
+
def test_san_castling_en_passant_and_promotion(self):
|
|
71
|
+
line = (
|
|
72
|
+
'cmp1 e2e4 e7e5 g1f3 b8c6 f1b5 a7a6 '
|
|
73
|
+
'b5a4 g8f6 e1g1'
|
|
74
|
+
)
|
|
75
|
+
self.assertTrue(cmp.convert_mnemonic(line, 'san').endswith('O-O'))
|
|
76
|
+
|
|
77
|
+
line = 'cmp1 e2e4 a7a6 e4e5 d7d5 e5d6'
|
|
78
|
+
self.assertTrue(cmp.convert_mnemonic(line, 'san').endswith('exd6'))
|
|
79
|
+
|
|
80
|
+
line = (
|
|
81
|
+
'cmp1 a2a4 h7h5 a4a5 h5h4 a5a6 h4h3 '
|
|
82
|
+
'a6b7 h3g2 b7a8q'
|
|
83
|
+
)
|
|
84
|
+
self.assertTrue(cmp.convert_mnemonic(line, 'san').endswith('bxa8=Q'))
|
|
85
|
+
|
|
86
|
+
def test_restart_after_terminal_position(self):
|
|
87
|
+
line = 'cmp1 e2e4 e7e5 d1h5 b8c6 f1c4 g8f6 h5f7 e2e4'
|
|
88
|
+
self.assertTrue(cmp.check(line))
|
|
89
|
+
self.assertEqual(
|
|
90
|
+
cmp.convert_mnemonic(line, 'san'),
|
|
91
|
+
'1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7#\n1. e4',
|
|
92
|
+
)
|
|
93
|
+
pgn = cmp.convert_mnemonic(line, 'pgn')
|
|
94
|
+
self.assertEqual(pgn.count('[Event "CMP-1"]'), 2)
|
|
95
|
+
|
|
96
|
+
def test_vectors(self):
|
|
97
|
+
path = os.path.join(ROOT, 'vectors.json')
|
|
98
|
+
with open(path, 'r', encoding='utf-8') as handle:
|
|
99
|
+
vectors = json.load(handle)
|
|
100
|
+
|
|
101
|
+
self.assertEqual(vectors['protocol'], cmp.VERSION)
|
|
102
|
+
|
|
103
|
+
for vector in vectors['valid']:
|
|
104
|
+
with self.subTest(vector=vector['name']):
|
|
105
|
+
self.assertTrue(cmp.check(vector['input']))
|
|
106
|
+
self.assertEqual(
|
|
107
|
+
cmp.normalize(vector['input']),
|
|
108
|
+
vector['canonical'],
|
|
109
|
+
)
|
|
110
|
+
self.assertEqual(
|
|
111
|
+
cmp.convert_mnemonic(vector['input'], 'uci'),
|
|
112
|
+
vector['uci'],
|
|
113
|
+
)
|
|
114
|
+
self.assertEqual(
|
|
115
|
+
cmp.convert_mnemonic(vector['input'], 'san'),
|
|
116
|
+
vector['san'],
|
|
117
|
+
)
|
|
118
|
+
if 'pgn_result' in vector:
|
|
119
|
+
result = '[Result "%s"]' % vector['pgn_result']
|
|
120
|
+
self.assertIn(
|
|
121
|
+
result,
|
|
122
|
+
cmp.convert_mnemonic(vector['input'], 'pgn'),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
for vector in vectors['invalid']:
|
|
126
|
+
with self.subTest(vector=vector['name']):
|
|
127
|
+
self.assertFalse(cmp.check(vector['input']))
|
|
128
|
+
with self.assertRaises(cmp.InvalidMnemonic):
|
|
129
|
+
cmp.parse_mnemonic(vector['input'])
|
|
130
|
+
|
|
131
|
+
def test_cli_check_and_convert(self):
|
|
132
|
+
stdout = io.StringIO()
|
|
133
|
+
with contextlib.redirect_stdout(stdout):
|
|
134
|
+
code = cmp.main(['check', 'cmp1', 'e2e4', 'e7e5'])
|
|
135
|
+
self.assertEqual(code, 0)
|
|
136
|
+
self.assertEqual(stdout.getvalue().strip(), 'valid')
|
|
137
|
+
|
|
138
|
+
stdout = io.StringIO()
|
|
139
|
+
with contextlib.redirect_stdout(stdout):
|
|
140
|
+
code = cmp.main([
|
|
141
|
+
'convert', '--to', 'san', 'cmp1', 'e2e4', 'e7e5'
|
|
142
|
+
])
|
|
143
|
+
self.assertEqual(code, 0)
|
|
144
|
+
self.assertEqual(stdout.getvalue().strip(), '1. e4 e5')
|
|
145
|
+
|
|
146
|
+
def test_cli_invalid_returns_nonzero(self):
|
|
147
|
+
stderr = io.StringIO()
|
|
148
|
+
with contextlib.redirect_stderr(stderr):
|
|
149
|
+
code = cmp.main(['check', 'cmp1', 'e2e5'])
|
|
150
|
+
self.assertEqual(code, 1)
|
|
151
|
+
self.assertIn('invalid:', stderr.getvalue())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ChessEngineTests(unittest.TestCase):
|
|
155
|
+
def perft(self, state, depth):
|
|
156
|
+
if depth == 0:
|
|
157
|
+
return 1
|
|
158
|
+
|
|
159
|
+
total = 0
|
|
160
|
+
for move in engine_legal_moves(state):
|
|
161
|
+
total += self.perft(play(state, move), depth - 1)
|
|
162
|
+
return total
|
|
163
|
+
|
|
164
|
+
def test_start_position_perft(self):
|
|
165
|
+
state = initial_state()
|
|
166
|
+
self.assertEqual(self.perft(state, 1), 20)
|
|
167
|
+
self.assertEqual(self.perft(state, 2), 400)
|
|
168
|
+
self.assertEqual(self.perft(state, 3), 8902)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == '__main__':
|
|
172
|
+
unittest.main()
|