ankigammon 1.0.6__py3-none-any.whl
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.
- ankigammon/__init__.py +7 -0
- ankigammon/__main__.py +6 -0
- ankigammon/analysis/__init__.py +13 -0
- ankigammon/analysis/score_matrix.py +391 -0
- ankigammon/anki/__init__.py +6 -0
- ankigammon/anki/ankiconnect.py +216 -0
- ankigammon/anki/apkg_exporter.py +111 -0
- ankigammon/anki/card_generator.py +1325 -0
- ankigammon/anki/card_styles.py +1054 -0
- ankigammon/gui/__init__.py +8 -0
- ankigammon/gui/app.py +192 -0
- ankigammon/gui/dialogs/__init__.py +10 -0
- ankigammon/gui/dialogs/export_dialog.py +594 -0
- ankigammon/gui/dialogs/import_options_dialog.py +201 -0
- ankigammon/gui/dialogs/input_dialog.py +762 -0
- ankigammon/gui/dialogs/note_dialog.py +93 -0
- ankigammon/gui/dialogs/settings_dialog.py +420 -0
- ankigammon/gui/dialogs/update_dialog.py +373 -0
- ankigammon/gui/format_detector.py +377 -0
- ankigammon/gui/main_window.py +1611 -0
- ankigammon/gui/resources/down-arrow.svg +3 -0
- ankigammon/gui/resources/icon.icns +0 -0
- ankigammon/gui/resources/icon.ico +0 -0
- ankigammon/gui/resources/icon.png +0 -0
- ankigammon/gui/resources/style.qss +402 -0
- ankigammon/gui/resources.py +26 -0
- ankigammon/gui/update_checker.py +259 -0
- ankigammon/gui/widgets/__init__.py +8 -0
- ankigammon/gui/widgets/position_list.py +166 -0
- ankigammon/gui/widgets/smart_input.py +268 -0
- ankigammon/models.py +356 -0
- ankigammon/parsers/__init__.py +7 -0
- ankigammon/parsers/gnubg_match_parser.py +1094 -0
- ankigammon/parsers/gnubg_parser.py +468 -0
- ankigammon/parsers/sgf_parser.py +290 -0
- ankigammon/parsers/xg_binary_parser.py +1097 -0
- ankigammon/parsers/xg_text_parser.py +688 -0
- ankigammon/renderer/__init__.py +5 -0
- ankigammon/renderer/animation_controller.py +391 -0
- ankigammon/renderer/animation_helper.py +191 -0
- ankigammon/renderer/color_schemes.py +145 -0
- ankigammon/renderer/svg_board_renderer.py +791 -0
- ankigammon/settings.py +315 -0
- ankigammon/thirdparty/__init__.py +7 -0
- ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
- ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
- ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
- ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
- ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
- ankigammon/utils/__init__.py +13 -0
- ankigammon/utils/gnubg_analyzer.py +590 -0
- ankigammon/utils/gnuid.py +577 -0
- ankigammon/utils/move_parser.py +204 -0
- ankigammon/utils/ogid.py +326 -0
- ankigammon/utils/xgid.py +387 -0
- ankigammon-1.0.6.dist-info/METADATA +352 -0
- ankigammon-1.0.6.dist-info/RECORD +61 -0
- ankigammon-1.0.6.dist-info/WHEEL +5 -0
- ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
- ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
- ankigammon-1.0.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""SGF (Smart Game Format) parser for backgammon.
|
|
2
|
+
|
|
3
|
+
SGF is a standard format for recording board games. For backgammon, it uses GM[6].
|
|
4
|
+
Specification: https://www.red-bean.com/sgf/backgammon.html
|
|
5
|
+
|
|
6
|
+
Move notation uses perspective-dependent point encoding:
|
|
7
|
+
- Points a-x represent points 1-24
|
|
8
|
+
- y = bar, z = off
|
|
9
|
+
|
|
10
|
+
For White (moving 1→24): a=1, b=2, c=3... x=24
|
|
11
|
+
For Black (moving 24→1): a=24, b=23, c=22... x=1
|
|
12
|
+
|
|
13
|
+
Example: B[52lqac] (Black rolls 5-2)
|
|
14
|
+
- lq: l(13) → q(8) = 13/8
|
|
15
|
+
- ac: a(24) → c(22) = 24/22
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from typing import List, Dict, Tuple, Optional
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SGFParser:
|
|
24
|
+
"""Parser for SGF backgammon match files."""
|
|
25
|
+
|
|
26
|
+
# SGF point letters (a-x = points, y = bar, z = off)
|
|
27
|
+
POINT_LETTERS = 'abcdefghijklmnopqrstuvwxyz'
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def sgf_letter_to_point(letter: str, player: str) -> Optional[int]:
|
|
31
|
+
"""Convert SGF letter to backgammon point number.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
letter: Single letter (a-x=points, y=bar, z=off)
|
|
35
|
+
player: 'W' for White or 'B' for Black
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Point number (1-24) or special values:
|
|
39
|
+
- 0 for bar
|
|
40
|
+
- 25 for off
|
|
41
|
+
- None for invalid letter
|
|
42
|
+
"""
|
|
43
|
+
if letter == 'y':
|
|
44
|
+
return 0
|
|
45
|
+
elif letter == 'z':
|
|
46
|
+
return 25
|
|
47
|
+
elif letter in SGFParser.POINT_LETTERS[:24]:
|
|
48
|
+
index = SGFParser.POINT_LETTERS.index(letter)
|
|
49
|
+
|
|
50
|
+
if player == 'W':
|
|
51
|
+
return index + 1
|
|
52
|
+
else:
|
|
53
|
+
return 24 - index
|
|
54
|
+
else:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def parse_sgf_move(move_str: str, player: str) -> Tuple[str, str]:
|
|
59
|
+
"""Parse SGF move notation to dice and standard notation.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
move_str: SGF move string like "52lqac" or "double"
|
|
63
|
+
player: 'W' for White or 'B' for Black
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Tuple of (dice, move_notation)
|
|
67
|
+
- dice: "52" or "" for special moves
|
|
68
|
+
- move_notation: "13/8 24/22" or "Doubles" etc.
|
|
69
|
+
"""
|
|
70
|
+
# Handle special cube actions
|
|
71
|
+
if move_str == 'double':
|
|
72
|
+
return "", "Doubles => 2"
|
|
73
|
+
elif move_str == 'take':
|
|
74
|
+
return "", "Takes"
|
|
75
|
+
elif move_str == 'drop':
|
|
76
|
+
return "", "Drops"
|
|
77
|
+
|
|
78
|
+
if len(move_str) < 2:
|
|
79
|
+
return "", ""
|
|
80
|
+
|
|
81
|
+
dice = move_str[:2]
|
|
82
|
+
if not dice.isdigit():
|
|
83
|
+
return "", ""
|
|
84
|
+
|
|
85
|
+
move_part = move_str[2:]
|
|
86
|
+
if len(move_part) % 2 != 0:
|
|
87
|
+
return dice, ""
|
|
88
|
+
|
|
89
|
+
moves = []
|
|
90
|
+
for i in range(0, len(move_part), 2):
|
|
91
|
+
from_letter = move_part[i]
|
|
92
|
+
to_letter = move_part[i + 1]
|
|
93
|
+
|
|
94
|
+
from_point = SGFParser.sgf_letter_to_point(from_letter, player)
|
|
95
|
+
to_point = SGFParser.sgf_letter_to_point(to_letter, player)
|
|
96
|
+
|
|
97
|
+
if from_point is None or to_point is None:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
from_str = 'bar' if from_point == 0 else str(from_point)
|
|
101
|
+
to_str = 'off' if to_point == 25 else str(to_point)
|
|
102
|
+
|
|
103
|
+
moves.append(f"{from_str}/{to_str}")
|
|
104
|
+
|
|
105
|
+
move_notation = " ".join(moves) if moves else ""
|
|
106
|
+
return dice, move_notation
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def parse_sgf_file(file_path: str) -> Dict:
|
|
110
|
+
"""Parse SGF file and extract match information.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
file_path: Path to .sgf file
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Dictionary with:
|
|
117
|
+
- player_white: White player name
|
|
118
|
+
- player_black: Black player name
|
|
119
|
+
- match_length: Match length (0 for money game)
|
|
120
|
+
- games: List of game dictionaries
|
|
121
|
+
"""
|
|
122
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
123
|
+
content = f.read()
|
|
124
|
+
|
|
125
|
+
games_raw = re.findall(r'\(([^()]+)\)', content)
|
|
126
|
+
|
|
127
|
+
games = []
|
|
128
|
+
player_white = None
|
|
129
|
+
player_black = None
|
|
130
|
+
match_length = 0
|
|
131
|
+
|
|
132
|
+
for game_raw in games_raw:
|
|
133
|
+
game_data = SGFParser._parse_game(game_raw)
|
|
134
|
+
|
|
135
|
+
if player_white is None:
|
|
136
|
+
player_white = game_data.get('player_white', 'White')
|
|
137
|
+
if player_black is None:
|
|
138
|
+
player_black = game_data.get('player_black', 'Black')
|
|
139
|
+
if match_length == 0:
|
|
140
|
+
match_length = game_data.get('match_length', 0)
|
|
141
|
+
|
|
142
|
+
games.append(game_data)
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
'player_white': player_white,
|
|
146
|
+
'player_black': player_black,
|
|
147
|
+
'match_length': match_length,
|
|
148
|
+
'games': games
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _parse_game(game_str: str) -> Dict:
|
|
153
|
+
"""Parse a single SGF game.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
game_str: SGF game string (content between parentheses)
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Dictionary with game metadata and moves
|
|
160
|
+
"""
|
|
161
|
+
game_data = {
|
|
162
|
+
'moves': []
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
parts = game_str.split(';')
|
|
166
|
+
|
|
167
|
+
for part in parts:
|
|
168
|
+
part = part.strip()
|
|
169
|
+
if not part:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
props = re.findall(r'([A-Z]+)\[([^\]]*)\]', part)
|
|
173
|
+
|
|
174
|
+
for prop_name, prop_value in props:
|
|
175
|
+
if prop_name == 'PW':
|
|
176
|
+
game_data['player_white'] = prop_value
|
|
177
|
+
elif prop_name == 'PB':
|
|
178
|
+
game_data['player_black'] = prop_value
|
|
179
|
+
elif prop_name == 'RE':
|
|
180
|
+
game_data['result'] = prop_value
|
|
181
|
+
elif prop_name == 'RU':
|
|
182
|
+
game_data['rules'] = prop_value
|
|
183
|
+
elif prop_name == 'MI':
|
|
184
|
+
mi_match = re.search(r'length:(\d+)', prop_value)
|
|
185
|
+
if mi_match:
|
|
186
|
+
game_data['match_length'] = int(mi_match.group(1))
|
|
187
|
+
|
|
188
|
+
game_match = re.search(r'game:(\d+)', prop_value)
|
|
189
|
+
if game_match:
|
|
190
|
+
game_data['game_number'] = int(game_match.group(1))
|
|
191
|
+
|
|
192
|
+
bs_match = re.search(r'bs:(\d+)', prop_value)
|
|
193
|
+
if bs_match:
|
|
194
|
+
game_data['black_score'] = int(bs_match.group(1))
|
|
195
|
+
|
|
196
|
+
ws_match = re.search(r'ws:(\d+)', prop_value)
|
|
197
|
+
if ws_match:
|
|
198
|
+
game_data['white_score'] = int(ws_match.group(1))
|
|
199
|
+
|
|
200
|
+
move_match = re.match(r'^([BW])\[([^\]]+)\]', part)
|
|
201
|
+
if move_match:
|
|
202
|
+
player = move_match.group(1)
|
|
203
|
+
move_str = move_match.group(2)
|
|
204
|
+
|
|
205
|
+
dice, notation = SGFParser.parse_sgf_move(move_str, player)
|
|
206
|
+
|
|
207
|
+
game_data['moves'].append({
|
|
208
|
+
'player': player,
|
|
209
|
+
'dice': dice,
|
|
210
|
+
'notation': notation,
|
|
211
|
+
'raw': move_str
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
return game_data
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def convert_to_mat_format(sgf_data: Dict) -> str:
|
|
218
|
+
"""Convert parsed SGF data to .mat (match text) format.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
sgf_data: Parsed SGF data from parse_sgf_file()
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
String in .mat format compatible with GnuBG
|
|
225
|
+
"""
|
|
226
|
+
lines = []
|
|
227
|
+
|
|
228
|
+
player_white = sgf_data.get('player_white', 'White')
|
|
229
|
+
player_black = sgf_data.get('player_black', 'Black')
|
|
230
|
+
match_length = sgf_data.get('match_length', 0)
|
|
231
|
+
|
|
232
|
+
lines.append(f" {match_length} point match")
|
|
233
|
+
lines.append("")
|
|
234
|
+
|
|
235
|
+
for game_idx, game in enumerate(sgf_data['games'], start=1):
|
|
236
|
+
white_score = game.get('white_score', 0)
|
|
237
|
+
black_score = game.get('black_score', 0)
|
|
238
|
+
|
|
239
|
+
lines.append(f" Game {game_idx}")
|
|
240
|
+
lines.append(f" {player_black} : {black_score} {player_white} : {white_score}")
|
|
241
|
+
|
|
242
|
+
move_num = 0
|
|
243
|
+
for move in game['moves']:
|
|
244
|
+
player_name = player_white if move['player'] == 'W' else player_black
|
|
245
|
+
dice = move['dice']
|
|
246
|
+
notation = move['notation']
|
|
247
|
+
|
|
248
|
+
if 'Doubles' in notation:
|
|
249
|
+
lines.append(f" {move_num}) Doubles => 2")
|
|
250
|
+
elif 'Takes' in notation:
|
|
251
|
+
lines.append(f" {move_num}) Takes")
|
|
252
|
+
elif 'Drops' in notation:
|
|
253
|
+
lines.append(f" {move_num}) Drops")
|
|
254
|
+
else:
|
|
255
|
+
if dice:
|
|
256
|
+
move_num += 1
|
|
257
|
+
lines.append(f" {move_num}) {dice}: {notation}")
|
|
258
|
+
|
|
259
|
+
result = game.get('result', '')
|
|
260
|
+
if result:
|
|
261
|
+
result_match = re.match(r'([WB])\+(\d+)', result)
|
|
262
|
+
if result_match:
|
|
263
|
+
winner = 'White' if result_match.group(1) == 'W' else 'Black'
|
|
264
|
+
points = result_match.group(2)
|
|
265
|
+
lines.append(f" Wins {points} point{'s' if points != '1' else ''}")
|
|
266
|
+
|
|
267
|
+
lines.append("")
|
|
268
|
+
|
|
269
|
+
return "\n".join(lines)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def extract_player_names_from_sgf(sgf_file_path: str) -> Tuple[str, str]:
|
|
273
|
+
"""Extract player names from SGF file.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
sgf_file_path: Path to .sgf file
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Tuple of (player1_name, player2_name) where:
|
|
280
|
+
- player1 goes to top checkbox (Player.O filter)
|
|
281
|
+
- player2 goes to bottom checkbox (Player.X filter)
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
data = SGFParser.parse_sgf_file(sgf_file_path)
|
|
285
|
+
return (
|
|
286
|
+
data.get('player_white', 'Player 1'),
|
|
287
|
+
data.get('player_black', 'Player 2')
|
|
288
|
+
)
|
|
289
|
+
except Exception:
|
|
290
|
+
return ('Player 1', 'Player 2')
|