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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ cmp = cmp:main
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()