ankigammon 1.0.0__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.

Potentially problematic release.


This version of ankigammon might be problematic. Click here for more details.

Files changed (56) hide show
  1. ankigammon/__init__.py +7 -0
  2. ankigammon/__main__.py +6 -0
  3. ankigammon/analysis/__init__.py +13 -0
  4. ankigammon/analysis/score_matrix.py +373 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +224 -0
  7. ankigammon/anki/apkg_exporter.py +123 -0
  8. ankigammon/anki/card_generator.py +1307 -0
  9. ankigammon/anki/card_styles.py +1034 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +209 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +597 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +163 -0
  15. ankigammon/gui/dialogs/input_dialog.py +776 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +384 -0
  18. ankigammon/gui/format_detector.py +292 -0
  19. ankigammon/gui/main_window.py +1071 -0
  20. ankigammon/gui/resources/icon.icns +0 -0
  21. ankigammon/gui/resources/icon.ico +0 -0
  22. ankigammon/gui/resources/icon.png +0 -0
  23. ankigammon/gui/resources/style.qss +394 -0
  24. ankigammon/gui/resources.py +26 -0
  25. ankigammon/gui/widgets/__init__.py +8 -0
  26. ankigammon/gui/widgets/position_list.py +193 -0
  27. ankigammon/gui/widgets/smart_input.py +268 -0
  28. ankigammon/models.py +322 -0
  29. ankigammon/parsers/__init__.py +7 -0
  30. ankigammon/parsers/gnubg_parser.py +454 -0
  31. ankigammon/parsers/xg_binary_parser.py +870 -0
  32. ankigammon/parsers/xg_text_parser.py +729 -0
  33. ankigammon/renderer/__init__.py +5 -0
  34. ankigammon/renderer/animation_controller.py +406 -0
  35. ankigammon/renderer/animation_helper.py +221 -0
  36. ankigammon/renderer/color_schemes.py +145 -0
  37. ankigammon/renderer/svg_board_renderer.py +824 -0
  38. ankigammon/settings.py +239 -0
  39. ankigammon/thirdparty/__init__.py +7 -0
  40. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  41. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  42. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  43. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  44. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  45. ankigammon/utils/__init__.py +13 -0
  46. ankigammon/utils/gnubg_analyzer.py +431 -0
  47. ankigammon/utils/gnuid.py +622 -0
  48. ankigammon/utils/move_parser.py +239 -0
  49. ankigammon/utils/ogid.py +335 -0
  50. ankigammon/utils/xgid.py +419 -0
  51. ankigammon-1.0.0.dist-info/METADATA +370 -0
  52. ankigammon-1.0.0.dist-info/RECORD +56 -0
  53. ankigammon-1.0.0.dist-info/WHEEL +5 -0
  54. ankigammon-1.0.0.dist-info/entry_points.txt +2 -0
  55. ankigammon-1.0.0.dist-info/licenses/LICENSE +21 -0
  56. ankigammon-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,239 @@
1
+ """Parse and apply backgammon move notation."""
2
+
3
+ import re
4
+ from typing import List, Tuple
5
+
6
+ from ankigammon.models import Position, Player
7
+
8
+
9
+ class MoveParser:
10
+ """Parse and apply backgammon move notation."""
11
+
12
+ @staticmethod
13
+ def parse_move_notation(notation: str) -> List[Tuple[int, int]]:
14
+ """
15
+ Parse move notation into list of (from, to) tuples.
16
+
17
+ Args:
18
+ notation: Move notation (e.g., "13/9 6/5", "bar/22", "6/off")
19
+
20
+ Returns:
21
+ List of (from_point, to_point) tuples
22
+ Use 0 for X bar, 25 for O bar, 26 for bearing off
23
+
24
+ Examples:
25
+ "13/9 6/5" -> [(13, 9), (6, 5)]
26
+ "bar/22" -> [(0, 22)] # X entering from bar
27
+ "6/off" -> [(6, 26)] # Bearing off
28
+ "6/4(4)" -> [(6, 4), (6, 4), (6, 4), (6, 4)] # Repetition notation
29
+ """
30
+ notation = notation.strip().lower()
31
+
32
+ # Handle special cases
33
+ if notation in ['double', 'take', 'drop', 'pass', 'accept', 'decline']:
34
+ return [] # Cube actions have no checker movement
35
+
36
+ moves = []
37
+
38
+ # Split by spaces or commas
39
+ parts = re.split(r'[\s,]+', notation)
40
+
41
+ for part in parts:
42
+ if not part or '/' not in part:
43
+ continue
44
+
45
+ # Check for repetition notation like "6/4(4)" meaning "move 4 checkers from 6 to 4"
46
+ repetition_count = 1
47
+ repetition_match = re.search(r'\((\d+)\)$', part)
48
+ if repetition_match:
49
+ repetition_count = int(repetition_match.group(1))
50
+ # Remove the repetition notation from the part
51
+ part = re.sub(r'\(\d+\)$', '', part)
52
+
53
+ # Handle compound notation like "6/5*/3" or "24/23/22"
54
+ # Split by '/' and remove asterisks to get all points in the sequence
55
+ segments = part.split('/')
56
+
57
+ # Remove asterisks from each segment
58
+ segments = [seg.rstrip('*') for seg in segments]
59
+
60
+ # If there are more than 2 segments, this is compound notation
61
+ # Convert it to consecutive moves: "6/5/3" -> [(6,5), (5,3)]
62
+ if len(segments) > 2:
63
+ for i in range(len(segments) - 1):
64
+ from_str = segments[i]
65
+ to_str = segments[i + 1]
66
+
67
+ # Parse 'from' point
68
+ if 'bar' in from_str:
69
+ from_point = 0 # X bar (we'll adjust for O later)
70
+ else:
71
+ try:
72
+ from_point = int(from_str)
73
+ except ValueError:
74
+ continue
75
+
76
+ # Parse 'to' point
77
+ if 'off' in to_str:
78
+ to_point = 26 # Bearing off
79
+ elif 'bar' in to_str:
80
+ to_point = 0 # Will be adjusted based on context
81
+ else:
82
+ try:
83
+ to_point = int(to_str)
84
+ except ValueError:
85
+ continue
86
+
87
+ # Add the move repetition_count times
88
+ for _ in range(repetition_count):
89
+ moves.append((from_point, to_point))
90
+ else:
91
+ # Simple notation like "6/5" or "bar/22"
92
+ from_str = segments[0]
93
+ to_str = segments[1] if len(segments) > 1 else ''
94
+
95
+ if not to_str:
96
+ continue
97
+
98
+ # Parse 'from' point
99
+ if 'bar' in from_str:
100
+ from_point = 0 # X bar (we'll adjust for O later)
101
+ else:
102
+ try:
103
+ from_point = int(from_str)
104
+ except ValueError:
105
+ continue
106
+
107
+ # Parse 'to' point
108
+ if 'off' in to_str:
109
+ to_point = 26 # Bearing off
110
+ elif 'bar' in to_str:
111
+ # Hit - destination is the bar (rare in notation)
112
+ to_point = 0 # Will be adjusted based on context
113
+ else:
114
+ try:
115
+ to_point = int(to_str)
116
+ except ValueError:
117
+ continue
118
+
119
+ # Add the move repetition_count times (handles notation like "6/4(4)")
120
+ for _ in range(repetition_count):
121
+ moves.append((from_point, to_point))
122
+
123
+ return moves
124
+
125
+ @staticmethod
126
+ def apply_move(position: Position, notation: str, player: Player) -> Position:
127
+ """
128
+ Apply a move to a position and return the resulting position.
129
+
130
+ Args:
131
+ position: Initial position
132
+ notation: Move notation
133
+ player: Player making the move
134
+
135
+ Returns:
136
+ New position after the move
137
+ """
138
+ new_pos = position.copy()
139
+ moves = MoveParser.parse_move_notation(notation)
140
+
141
+ for from_point, to_point in moves:
142
+ # In backgammon notation, both players use the SAME numbering (1-24)
143
+ # The position array also uses this same numbering:
144
+ # - points[1] = point 1 (O's 1-point)
145
+ # - points[24] = point 24 (X's 1-point, O's 24-point)
146
+ #
147
+ # X moves from high numbers to low (24->1), O moves from low to high (1->24)
148
+ # No coordinate conversion is needed - notation matches position indices directly!
149
+
150
+ # The only special handling needed is for bar points:
151
+ # - X's bar is at position[0]
152
+ # - O's bar is at position[25]
153
+ # parse_move_notation() returns 0 for "bar", so correct it for O
154
+ if from_point == 0 and player == Player.O:
155
+ from_point = 25
156
+ if to_point == 0 and player == Player.X:
157
+ to_point = 25 # When X hits, opponent goes to bar 25
158
+
159
+ # Move checker
160
+ if from_point == 26: # Bearing off (from)
161
+ # This shouldn't happen in normal notation
162
+ continue
163
+
164
+ # Remove from source
165
+ if new_pos.points[from_point] == 0:
166
+ # Invalid move - no checker to move
167
+ continue
168
+
169
+ if player == Player.X:
170
+ if new_pos.points[from_point] > 0:
171
+ new_pos.points[from_point] -= 1
172
+ else:
173
+ # Wrong player's checker
174
+ continue
175
+ else: # Player.O
176
+ if new_pos.points[from_point] < 0:
177
+ new_pos.points[from_point] += 1
178
+ else:
179
+ # Wrong player's checker
180
+ continue
181
+
182
+ # Add to destination
183
+ if to_point == 26: # Bearing off
184
+ if player == Player.X:
185
+ new_pos.x_off += 1
186
+ else:
187
+ new_pos.o_off += 1
188
+ else:
189
+ # Check for hitting
190
+ target_count = new_pos.points[to_point]
191
+
192
+ if player == Player.X:
193
+ if target_count == -1:
194
+ # Hit O's blot
195
+ new_pos.points[25] -= 1 # Send to O's bar
196
+ new_pos.points[to_point] = 1
197
+ else:
198
+ new_pos.points[to_point] += 1
199
+ else: # Player.O
200
+ if target_count == 1:
201
+ # Hit X's blot
202
+ new_pos.points[0] += 1 # Send to X's bar
203
+ new_pos.points[to_point] = -1
204
+ else:
205
+ new_pos.points[to_point] -= 1
206
+
207
+ return new_pos
208
+
209
+ @staticmethod
210
+ def format_move(from_point: int, to_point: int, player: Player) -> str:
211
+ """
212
+ Format a single move as notation.
213
+
214
+ Args:
215
+ from_point: Source point (0-25, or 26 for bearing off)
216
+ to_point: Destination point
217
+ player: Player making the move
218
+
219
+ Returns:
220
+ Move notation string (e.g., "13/9", "bar/22", "6/off")
221
+ """
222
+ # Handle special points
223
+ if from_point == 0 and player == Player.X:
224
+ from_str = "bar"
225
+ elif from_point == 25 and player == Player.O:
226
+ from_str = "bar"
227
+ else:
228
+ from_str = str(from_point)
229
+
230
+ if to_point == 26:
231
+ to_str = "off"
232
+ elif to_point == 0 and player == Player.O:
233
+ to_str = "bar"
234
+ elif to_point == 25 and player == Player.X:
235
+ to_str = "bar"
236
+ else:
237
+ to_str = str(to_point)
238
+
239
+ return f"{from_str}/{to_str}"
@@ -0,0 +1,335 @@
1
+ """OGID format parsing and encoding.
2
+
3
+ OGID (OpenGammon Position ID) is a colon-separated format for representing
4
+ complete backgammon board states.
5
+
6
+ Format: P1:P2:CUBE[:DICE[:TURN[:STATE[:S1[:S2[:ML[:MID[:NCHECKERS]]]]]]]]
7
+
8
+ Fields:
9
+ 1. P1 (White/X checkers): Base-26 encoded positions with repeated characters
10
+ 2. P2 (Black/O checkers): Base-26 encoded positions with repeated characters
11
+ 3. CUBE: Three-character cube state (owner, value, action)
12
+ 4. DICE: Two-character dice roll (optional)
13
+ 5. TURN: Player to move - W or B (optional)
14
+ 6. STATE: Two-character game state (optional)
15
+ 7. S1: White/X score (optional)
16
+ 8. S2: Black/O score (optional)
17
+ 9. ML: Match length with modifiers (optional)
18
+ 10. MID: Move ID (optional)
19
+ 11. NCHECKERS: Number of checkers per side (optional, default 15)
20
+
21
+ Position encoding uses base-26:
22
+ - Characters '0'-'9' = points 0-9
23
+ - Characters 'a'-'p' = points 10-25
24
+ - Point 0 = White's bar (X in our model)
25
+ - Points 1-24 = board points
26
+ - Point 25 = Black's bar (O in our model)
27
+ - Repeated characters = multiple checkers on same point
28
+
29
+ Example starting position:
30
+ White: 11jjjjjhhhccccc (2 on pt1, 5 on pt9, 3 on pt17, 5 on pt12)
31
+ Black: ooddddd88866666 (2 on pt24, 5 on pt13, 3 on pt8, 5 on pt6)
32
+ Full: 11jjjjjhhhccccc:ooddddd88866666:N0N::W:IW:0:0:1:0
33
+
34
+ Note: In our internal model:
35
+ - White = Player.X (TOP player)
36
+ - Black = Player.O (BOTTOM player)
37
+ - Positive values = X checkers
38
+ - Negative values = O checkers
39
+ """
40
+
41
+ import re
42
+ from typing import Optional, Tuple, Dict
43
+
44
+ from ankigammon.models import Position, Player, CubeState
45
+
46
+
47
+ # Character to point mapping for base-26 encoding
48
+ def _char_to_point(char: str) -> int:
49
+ """Convert a character to a point number (0-25)."""
50
+ if '0' <= char <= '9':
51
+ return ord(char) - ord('0')
52
+ elif 'a' <= char <= 'p':
53
+ return ord(char) - ord('a') + 10
54
+ else:
55
+ raise ValueError(f"Invalid position character: {char}")
56
+
57
+
58
+ def _point_to_char(point: int) -> str:
59
+ """Convert a point number (0-25) to a character."""
60
+ if 0 <= point <= 9:
61
+ return chr(ord('0') + point)
62
+ elif 10 <= point <= 25:
63
+ return chr(ord('a') + point - 10)
64
+ else:
65
+ raise ValueError(f"Invalid point number: {point}")
66
+
67
+
68
+ def parse_ogid(ogid: str) -> Tuple[Position, Dict]:
69
+ """
70
+ Parse an OGID string into a Position and metadata.
71
+
72
+ Args:
73
+ ogid: OGID string (e.g., "11jjjjjhhhccccc:ooddddd88866666:N0N::W:IW:0:0:1:0")
74
+
75
+ Returns:
76
+ Tuple of (Position, metadata_dict)
77
+ """
78
+ # Remove "OGID=" prefix if present
79
+ if ogid.upper().startswith("OGID="):
80
+ ogid = ogid[5:]
81
+
82
+ # Split into components
83
+ parts = ogid.split(':')
84
+ if len(parts) < 3:
85
+ raise ValueError(f"Invalid OGID format: expected at least 3 parts, got {len(parts)}")
86
+
87
+ white_pos = parts[0] # White/X checkers
88
+ black_pos = parts[1] # Black/O checkers
89
+ cube_str = parts[2] # Cube state
90
+
91
+ # Parse position
92
+ position = _parse_ogid_position(white_pos, black_pos)
93
+
94
+ # Parse metadata
95
+ metadata = {}
96
+
97
+ # Parse cube state (3 characters: owner, value, action)
98
+ if len(cube_str) == 3:
99
+ cube_owner_char = cube_str[0]
100
+ cube_value_char = cube_str[1]
101
+ cube_action_char = cube_str[2]
102
+
103
+ # Cube value: 0->1, 1->2, 2->4, etc.
104
+ cube_value_log = int(cube_value_char)
105
+ metadata['cube_value'] = 2 ** cube_value_log
106
+
107
+ # Cube owner: W=White/X, B=Black/O, N=Neutral, D=Dead
108
+ if cube_owner_char == 'W':
109
+ metadata['cube_owner'] = CubeState.X_OWNS # White = X in our model
110
+ elif cube_owner_char == 'B':
111
+ metadata['cube_owner'] = CubeState.O_OWNS # Black = O in our model
112
+ elif cube_owner_char == 'N':
113
+ metadata['cube_owner'] = CubeState.CENTERED
114
+ else: # D or ?
115
+ metadata['cube_owner'] = CubeState.CENTERED # Default to centered
116
+
117
+ # Cube action (O=Offered, T=Taken, P=Passed, N=Normal)
118
+ metadata['cube_action'] = cube_action_char
119
+
120
+ # Parse optional fields
121
+ if len(parts) > 3 and parts[3]:
122
+ # Field 4: Dice
123
+ dice_str = parts[3]
124
+ if len(dice_str) == 2 and dice_str.isdigit():
125
+ d1 = int(dice_str[0])
126
+ d2 = int(dice_str[1])
127
+ if 1 <= d1 <= 6 and 1 <= d2 <= 6:
128
+ metadata['dice'] = (d1, d2)
129
+
130
+ if len(parts) > 4 and parts[4]:
131
+ # Field 5: Turn (W or B) - represents who is on roll
132
+ # W = White/X on roll, B = Black/O on roll
133
+ turn_str = parts[4].upper()
134
+ if turn_str == 'W':
135
+ metadata['on_roll'] = Player.X # White = X
136
+ elif turn_str == 'B':
137
+ metadata['on_roll'] = Player.O # Black = O
138
+
139
+ if len(parts) > 5 and parts[5]:
140
+ # Field 6: Game state (e.g., IW, FB)
141
+ metadata['game_state'] = parts[5]
142
+
143
+ if len(parts) > 6 and parts[6]:
144
+ # Field 7: White/X score
145
+ metadata['score_x'] = int(parts[6])
146
+
147
+ if len(parts) > 7 and parts[7]:
148
+ # Field 8: Black/O score
149
+ metadata['score_o'] = int(parts[7])
150
+
151
+ if len(parts) > 8 and parts[8]:
152
+ # Field 9: Match length (e.g., "7", "5C", "9G15")
153
+ match_str = parts[8]
154
+ match_regex = re.compile(r'(\d+)([LCG]?)(\d*)')
155
+ match = match_regex.match(match_str)
156
+ if match:
157
+ metadata['match_length'] = int(match.group(1))
158
+ if match.group(2):
159
+ metadata['match_modifier'] = match.group(2) # L, C, or G
160
+ if match.group(3):
161
+ metadata['match_max_games'] = int(match.group(3))
162
+
163
+ if len(parts) > 9 and parts[9]:
164
+ # Field 10: Move ID
165
+ metadata['move_id'] = int(parts[9])
166
+
167
+ if len(parts) > 10 and parts[10]:
168
+ # Field 11: Number of checkers per side (default 15)
169
+ metadata['num_checkers'] = int(parts[10])
170
+
171
+ return position, metadata
172
+
173
+
174
+ def _parse_ogid_position(white_str: str, black_str: str) -> Position:
175
+ """
176
+ Parse OGID position strings into a Position object.
177
+
178
+ Args:
179
+ white_str: White/X checker positions (e.g., "11jjjjjhhhccccc")
180
+ black_str: Black/O checker positions (e.g., "ooddddd88866666")
181
+
182
+ Returns:
183
+ Position object with checkers placed
184
+ """
185
+ position = Position()
186
+
187
+ # Parse White/X checkers (positive in our model)
188
+ for char in white_str:
189
+ point = _char_to_point(char)
190
+ position.points[point] += 1 # Positive for X
191
+
192
+ # Parse Black/O checkers (negative in our model)
193
+ for char in black_str:
194
+ point = _char_to_point(char)
195
+ position.points[point] -= 1 # Negative for O
196
+
197
+ # Calculate borne-off checkers (each player starts with 15)
198
+ total_x = sum(count for count in position.points if count > 0)
199
+ total_o = sum(abs(count) for count in position.points if count < 0)
200
+
201
+ position.x_off = 15 - total_x
202
+ position.o_off = 15 - total_o
203
+
204
+ return position
205
+
206
+
207
+ def encode_ogid(
208
+ position: Position,
209
+ cube_value: int = 1,
210
+ cube_owner: CubeState = CubeState.CENTERED,
211
+ cube_action: str = 'N',
212
+ dice: Optional[Tuple[int, int]] = None,
213
+ on_roll: Optional[Player] = None,
214
+ game_state: str = '',
215
+ score_x: int = 0,
216
+ score_o: int = 0,
217
+ match_length: Optional[int] = None,
218
+ match_modifier: str = '',
219
+ match_max_games: Optional[int] = None,
220
+ move_id: Optional[int] = None,
221
+ num_checkers: Optional[int] = None,
222
+ only_position: bool = False,
223
+ ) -> str:
224
+ """
225
+ Encode a position and metadata as an OGID string.
226
+
227
+ Args:
228
+ position: The position to encode
229
+ cube_value: Doubling cube value
230
+ cube_owner: Who owns the cube
231
+ cube_action: Cube action (N=Normal, O=Offered, T=Taken, P=Passed)
232
+ dice: Dice values (if any)
233
+ on_roll: Player on roll
234
+ game_state: Game state code (e.g., "IW", "FB")
235
+ score_x: White/X player's score
236
+ score_o: Black/O player's score
237
+ match_length: Match length (points to win)
238
+ match_modifier: Match modifier (L, C, or G)
239
+ match_max_games: Max games for Galaxie format
240
+ move_id: Move sequence number
241
+ num_checkers: Number of checkers per side (only include if not 15)
242
+ only_position: If True, only encode position fields (1-3)
243
+
244
+ Returns:
245
+ OGID string
246
+ """
247
+ # Encode position strings
248
+ white_chars = [] # X/White checkers
249
+ black_chars = [] # O/Black checkers
250
+
251
+ for point_idx in range(26):
252
+ count = position.points[point_idx]
253
+ if count > 0:
254
+ # X/White checkers (positive)
255
+ white_chars.extend([_point_to_char(point_idx)] * count)
256
+ elif count < 0:
257
+ # O/Black checkers (negative)
258
+ black_chars.extend([_point_to_char(point_idx)] * abs(count))
259
+
260
+ # Sort characters (OGID format convention)
261
+ white_str = ''.join(sorted(white_chars))
262
+ black_str = ''.join(sorted(black_chars))
263
+
264
+ # Encode cube state (3 characters)
265
+ # Owner
266
+ if cube_owner == CubeState.X_OWNS:
267
+ cube_owner_char = 'W' # White = X
268
+ elif cube_owner == CubeState.O_OWNS:
269
+ cube_owner_char = 'B' # Black = O
270
+ else:
271
+ cube_owner_char = 'N' # Neutral
272
+
273
+ # Value as log2
274
+ cube_value_log = 0
275
+ temp = cube_value
276
+ while temp > 1:
277
+ temp //= 2
278
+ cube_value_log += 1
279
+ cube_value_char = str(cube_value_log)
280
+
281
+ # Action
282
+ cube_action_char = cube_action
283
+
284
+ cube_str = f"{cube_owner_char}{cube_value_char}{cube_action_char}"
285
+
286
+ # Build OGID string
287
+ ogid_parts = [white_str, black_str, cube_str]
288
+
289
+ # If only_position is True, return just the first 3 fields
290
+ if only_position:
291
+ return ':'.join(ogid_parts)
292
+
293
+ # Add optional fields
294
+ # Field 4: Dice
295
+ if dice:
296
+ ogid_parts.append(f"{dice[0]}{dice[1]}")
297
+ else:
298
+ ogid_parts.append('')
299
+
300
+ # Field 5: Turn
301
+ if on_roll:
302
+ turn_char = 'W' if on_roll == Player.X else 'B'
303
+ ogid_parts.append(turn_char)
304
+ else:
305
+ ogid_parts.append('')
306
+
307
+ # Field 6: Game state
308
+ ogid_parts.append(game_state)
309
+
310
+ # Field 7-8: Scores
311
+ ogid_parts.append(str(score_x))
312
+ ogid_parts.append(str(score_o))
313
+
314
+ # Field 9: Match length
315
+ if match_length is not None:
316
+ match_str = str(match_length)
317
+ if match_modifier:
318
+ match_str += match_modifier
319
+ if match_max_games is not None:
320
+ match_str += str(match_max_games)
321
+ ogid_parts.append(match_str)
322
+ else:
323
+ ogid_parts.append('')
324
+
325
+ # Field 10: Move ID
326
+ if move_id is not None:
327
+ ogid_parts.append(str(move_id))
328
+ else:
329
+ ogid_parts.append('')
330
+
331
+ # Field 11: Number of checkers (only if not 15)
332
+ if num_checkers is not None and num_checkers != 15:
333
+ ogid_parts.append(str(num_checkers))
334
+
335
+ return ':'.join(ogid_parts)