pymdownx-mahjong 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.
Files changed (94) hide show
  1. pymdownx_mahjong/__init__.py +23 -0
  2. pymdownx_mahjong/assets/dark/0m.svg +333 -0
  3. pymdownx_mahjong/assets/dark/0p.svg +491 -0
  4. pymdownx_mahjong/assets/dark/0s.svg +678 -0
  5. pymdownx_mahjong/assets/dark/1m.svg +273 -0
  6. pymdownx_mahjong/assets/dark/1p.svg +551 -0
  7. pymdownx_mahjong/assets/dark/1s.svg +764 -0
  8. pymdownx_mahjong/assets/dark/1z.svg +242 -0
  9. pymdownx_mahjong/assets/dark/2m.svg +283 -0
  10. pymdownx_mahjong/assets/dark/2p.svg +396 -0
  11. pymdownx_mahjong/assets/dark/2s.svg +369 -0
  12. pymdownx_mahjong/assets/dark/2z.svg +270 -0
  13. pymdownx_mahjong/assets/dark/3m.svg +289 -0
  14. pymdownx_mahjong/assets/dark/3p.svg +356 -0
  15. pymdownx_mahjong/assets/dark/3s.svg +462 -0
  16. pymdownx_mahjong/assets/dark/3z.svg +248 -0
  17. pymdownx_mahjong/assets/dark/4m.svg +289 -0
  18. pymdownx_mahjong/assets/dark/4p.svg +403 -0
  19. pymdownx_mahjong/assets/dark/4s.svg +570 -0
  20. pymdownx_mahjong/assets/dark/4z.svg +236 -0
  21. pymdownx_mahjong/assets/dark/5m.svg +313 -0
  22. pymdownx_mahjong/assets/dark/5p.svg +450 -0
  23. pymdownx_mahjong/assets/dark/5s.svg +608 -0
  24. pymdownx_mahjong/assets/dark/5z.svg +214 -0
  25. pymdownx_mahjong/assets/dark/6m.svg +294 -0
  26. pymdownx_mahjong/assets/dark/6p.svg +473 -0
  27. pymdownx_mahjong/assets/dark/6s.svg +695 -0
  28. pymdownx_mahjong/assets/dark/6z.svg +324 -0
  29. pymdownx_mahjong/assets/dark/7m.svg +283 -0
  30. pymdownx_mahjong/assets/dark/7p.svg +516 -0
  31. pymdownx_mahjong/assets/dark/7s.svg +628 -0
  32. pymdownx_mahjong/assets/dark/7z.svg +232 -0
  33. pymdownx_mahjong/assets/dark/8m.svg +283 -0
  34. pymdownx_mahjong/assets/dark/8p.svg +566 -0
  35. pymdownx_mahjong/assets/dark/8s.svg +712 -0
  36. pymdownx_mahjong/assets/dark/9m.svg +289 -0
  37. pymdownx_mahjong/assets/dark/9p.svg +609 -0
  38. pymdownx_mahjong/assets/dark/9s.svg +762 -0
  39. pymdownx_mahjong/assets/dark/back.svg +285 -0
  40. pymdownx_mahjong/assets/dark/blank.svg +225 -0
  41. pymdownx_mahjong/assets/dark/front.svg +334 -0
  42. pymdownx_mahjong/assets/light/0m.svg +319 -0
  43. pymdownx_mahjong/assets/light/0p.svg +460 -0
  44. pymdownx_mahjong/assets/light/0s.svg +614 -0
  45. pymdownx_mahjong/assets/light/1m.svg +276 -0
  46. pymdownx_mahjong/assets/light/1p.svg +544 -0
  47. pymdownx_mahjong/assets/light/1s.svg +764 -0
  48. pymdownx_mahjong/assets/light/1z.svg +242 -0
  49. pymdownx_mahjong/assets/light/2m.svg +286 -0
  50. pymdownx_mahjong/assets/light/2p.svg +400 -0
  51. pymdownx_mahjong/assets/light/2s.svg +372 -0
  52. pymdownx_mahjong/assets/light/2z.svg +270 -0
  53. pymdownx_mahjong/assets/light/3m.svg +292 -0
  54. pymdownx_mahjong/assets/light/3p.svg +362 -0
  55. pymdownx_mahjong/assets/light/3s.svg +462 -0
  56. pymdownx_mahjong/assets/light/3z.svg +248 -0
  57. pymdownx_mahjong/assets/light/4m.svg +292 -0
  58. pymdownx_mahjong/assets/light/4p.svg +407 -0
  59. pymdownx_mahjong/assets/light/4s.svg +573 -0
  60. pymdownx_mahjong/assets/light/4z.svg +236 -0
  61. pymdownx_mahjong/assets/light/5m.svg +313 -0
  62. pymdownx_mahjong/assets/light/5p.svg +454 -0
  63. pymdownx_mahjong/assets/light/5s.svg +608 -0
  64. pymdownx_mahjong/assets/light/5z.svg +214 -0
  65. pymdownx_mahjong/assets/light/6m.svg +297 -0
  66. pymdownx_mahjong/assets/light/6p.svg +477 -0
  67. pymdownx_mahjong/assets/light/6s.svg +688 -0
  68. pymdownx_mahjong/assets/light/6z.svg +309 -0
  69. pymdownx_mahjong/assets/light/7m.svg +283 -0
  70. pymdownx_mahjong/assets/light/7p.svg +567 -0
  71. pymdownx_mahjong/assets/light/7s.svg +628 -0
  72. pymdownx_mahjong/assets/light/7z.svg +232 -0
  73. pymdownx_mahjong/assets/light/8m.svg +286 -0
  74. pymdownx_mahjong/assets/light/8p.svg +570 -0
  75. pymdownx_mahjong/assets/light/8s.svg +712 -0
  76. pymdownx_mahjong/assets/light/9m.svg +292 -0
  77. pymdownx_mahjong/assets/light/9p.svg +623 -0
  78. pymdownx_mahjong/assets/light/9s.svg +748 -0
  79. pymdownx_mahjong/assets/light/back.svg +285 -0
  80. pymdownx_mahjong/assets/light/blank.svg +224 -0
  81. pymdownx_mahjong/assets/light/front.svg +334 -0
  82. pymdownx_mahjong/css/mahjong.css +416 -0
  83. pymdownx_mahjong/extension.py +206 -0
  84. pymdownx_mahjong/inline.py +78 -0
  85. pymdownx_mahjong/parser.py +363 -0
  86. pymdownx_mahjong/renderer.py +468 -0
  87. pymdownx_mahjong/superfences.py +126 -0
  88. pymdownx_mahjong/tiles.py +85 -0
  89. pymdownx_mahjong/utils.py +90 -0
  90. pymdownx_mahjong-1.0.0.dist-info/METADATA +44 -0
  91. pymdownx_mahjong-1.0.0.dist-info/RECORD +94 -0
  92. pymdownx_mahjong-1.0.0.dist-info/WHEEL +4 -0
  93. pymdownx_mahjong-1.0.0.dist-info/entry_points.txt +2 -0
  94. pymdownx_mahjong-1.0.0.dist-info/licenses/LICENSE +352 -0
@@ -0,0 +1,363 @@
1
+ """MPSZ notation parser for Riichi Mahjong hands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+
9
+ from .tiles import TileInfo, get_tile_info, is_valid_tile
10
+
11
+
12
+ class MeldType(Enum):
13
+ """Types of called tile groups (melds)."""
14
+
15
+ CHI = "chi"
16
+ PON = "pon"
17
+ KAN_OPEN = "kan_open"
18
+ KAN_CLOSED = "kan_closed"
19
+ KAN_ADDED = "kan_added"
20
+
21
+
22
+ class MeldSource(Enum):
23
+ """Source direction for called tiles."""
24
+
25
+ LEFT = "<" # Kamicha
26
+ ACROSS = "^" # Toimen
27
+ RIGHT = ">" # Shimocha
28
+ SELF = "" # Self-drawn
29
+
30
+
31
+ @dataclass
32
+ class Tile:
33
+ """Represents a single mahjong tile."""
34
+
35
+ suit: str # m, p, s, z
36
+ number: int # 0-9 for suited, 1-7 for honors
37
+ is_rotated: bool = False # Is this tile rotated (called)?
38
+ is_added: bool = False # Is this the added tile in a shouminkan?
39
+
40
+ @property
41
+ def notation(self) -> str:
42
+ """Return the MPSZ notation for this tile."""
43
+ return f"{self.number}{self.suit}"
44
+
45
+ @property
46
+ def info(self) -> TileInfo | None:
47
+ """Get the tile info from the database."""
48
+ return get_tile_info(self.suit, self.number)
49
+
50
+ @property
51
+ def display_name(self) -> str:
52
+ """Get the human-readable tile name."""
53
+ info = self.info
54
+ return info.display_name if info else f"Unknown ({self.notation})"
55
+
56
+ def __str__(self) -> str:
57
+ return self.notation
58
+
59
+
60
+ @dataclass
61
+ class Meld:
62
+ """Represents a called tile group (meld)."""
63
+
64
+ tiles: list[Tile]
65
+ meld_type: MeldType
66
+ source: MeldSource = MeldSource.SELF
67
+
68
+ @property
69
+ def is_open(self) -> bool:
70
+ """Check if this meld is open (visible to other players)."""
71
+ return self.meld_type != MeldType.KAN_CLOSED
72
+
73
+ @property
74
+ def tile_count(self) -> int:
75
+ """Return the number of tiles in this meld."""
76
+ return len(self.tiles)
77
+
78
+
79
+ @dataclass
80
+ class Hand:
81
+ """Represents a complete mahjong hand."""
82
+
83
+ closed_tiles: list[Tile] = field(default_factory=list)
84
+ melds: list[Meld] = field(default_factory=list)
85
+ dora_indicators: list[Tile] = field(default_factory=list)
86
+ uradora_indicators: list[Tile] = field(default_factory=list)
87
+ draw_tile: Tile | None = None # The drawn tile (tsumo), displayed separately
88
+
89
+ @property
90
+ def all_tiles(self) -> list[Tile]:
91
+ """Get all tiles in the hand (including draw tile if present)."""
92
+ tiles = list(self.closed_tiles)
93
+ for meld in self.melds:
94
+ tiles.extend(meld.tiles)
95
+ if self.draw_tile:
96
+ tiles.append(self.draw_tile)
97
+ return tiles
98
+
99
+ @property
100
+ def meld_count(self) -> int:
101
+ """Return the number of melds in the hand."""
102
+ return len(self.melds)
103
+
104
+ @property
105
+ def total_tile_count(self) -> int:
106
+ """Return total number of tiles in the hand (closed + melds + draw)."""
107
+ count = len(self.closed_tiles) + sum(m.tile_count for m in self.melds)
108
+ if self.draw_tile:
109
+ count += 1
110
+ return count
111
+
112
+
113
+ class ParseError(Exception):
114
+ """Exception raised when parsing fails."""
115
+
116
+ pass
117
+
118
+
119
+ class MahjongParser:
120
+ """Parser for MPSZ mahjong notation.
121
+
122
+ Supports notation like:
123
+ - Basic: 123m456p789s1122z
124
+ - Red dora: 1230m (0 = red 5)
125
+ - Melds: (123m<) for chi, [1111m] for closed kan
126
+ - Separators: | before melds, _ for gaps
127
+ """
128
+
129
+ # Pattern for a group of numbers followed by a suit
130
+ TILE_GROUP_PATTERN = re.compile(r"([0-9]+)([mpsz])")
131
+
132
+ # Pattern for melds: (tiles<) or [tiles] with optional source marker inside brackets
133
+ # For added kan, use + to mark the added tile: (111+1m<)
134
+ MELD_PATTERN = re.compile(r"(\[|\()([0-9]+)(\+)?([0-9])?([mpsz])([<^>])?(\]|\))")
135
+
136
+ def __init__(self) -> None:
137
+ self.errors: list[str] = []
138
+
139
+ def parse(self, notation: str) -> Hand:
140
+ """Parse a hand notation string into a Hand object.
141
+
142
+ Args:
143
+ notation: MPSZ notation string
144
+
145
+ Returns:
146
+ Parsed Hand object
147
+
148
+ Raises:
149
+ ParseError: If the notation is invalid
150
+ """
151
+ self.errors = []
152
+ hand = Hand()
153
+
154
+ # Normalize whitespace
155
+ notation = notation.strip()
156
+
157
+ # Extract melds first (they're unambiguous with brackets)
158
+ hand.melds = self._parse_melds(notation)
159
+
160
+ # Remove meld notation from string to get closed tiles
161
+ closed_part = self.MELD_PATTERN.sub("", notation).strip()
162
+
163
+ # Parse closed tiles
164
+ hand.closed_tiles = self._parse_tiles(closed_part)
165
+
166
+ if self.errors:
167
+ raise ParseError("; ".join(self.errors))
168
+
169
+ return hand
170
+
171
+ def parse_tiles(self, notation: str) -> list[Tile]:
172
+ """Parse a simple tile notation string.
173
+
174
+ Args:
175
+ notation: MPSZ notation like "123m456p"
176
+
177
+ Returns:
178
+ List of Tile objects
179
+ """
180
+ self.errors = []
181
+ return self._parse_tiles(notation)
182
+
183
+ def _parse_tiles(self, notation: str) -> list[Tile]:
184
+ """Internal method to parse tile notation.
185
+
186
+ Args:
187
+ notation: Tile notation string
188
+
189
+ Returns:
190
+ List of tiles
191
+ """
192
+ tiles: list[Tile] = []
193
+
194
+ # Remove separators and whitespace
195
+ clean = notation.replace("_", "").replace(" ", "")
196
+
197
+ # Find all tile groups
198
+ for match in self.TILE_GROUP_PATTERN.finditer(clean):
199
+ numbers = match.group(1)
200
+ suit = match.group(2)
201
+
202
+ for num_char in numbers:
203
+ number = int(num_char)
204
+
205
+ if not is_valid_tile(suit, number):
206
+ self.errors.append(f"Invalid tile: {number}{suit}")
207
+ continue
208
+
209
+ tiles.append(Tile(suit=suit, number=number))
210
+
211
+ return tiles
212
+
213
+ def _parse_melds(self, notation: str) -> list[Meld]:
214
+ """Parse meld notation.
215
+
216
+ Args:
217
+ notation: Meld notation like "(123m<) [1111z]"
218
+ For added kan (shouminkan), use + before the added tile: "(111+1m<)"
219
+
220
+ Returns:
221
+ List of Meld objects
222
+ """
223
+ melds: list[Meld] = []
224
+
225
+ for match in self.MELD_PATTERN.finditer(notation):
226
+ open_bracket = match.group(1)
227
+ base_numbers = match.group(2) # Numbers before the +
228
+ plus_marker = match.group(3) # The + if present
229
+ added_number = match.group(4) # Number after + if present
230
+ suit = match.group(5)
231
+ source_char = match.group(6) or ""
232
+ close_bracket = match.group(7)
233
+
234
+ # Reject mismatched brackets
235
+ if (open_bracket == "[" and close_bracket != "]") or (open_bracket == "(" and close_bracket != ")"):
236
+ self.errors.append(f"Mismatched brackets in meld: {match.group(0)}")
237
+ continue
238
+
239
+ # Reject added kan notation without digit after '+'
240
+ if plus_marker and not added_number:
241
+ self.errors.append(f"Added kan notation requires digit after '+': {match.group(0)}")
242
+ continue
243
+
244
+ # Build the tile notation
245
+ if plus_marker and added_number:
246
+ # Added kan: combine base tiles and added tile
247
+ tile_notation = base_numbers + added_number + suit
248
+ is_added_kan = True
249
+ else:
250
+ tile_notation = base_numbers + suit
251
+ is_added_kan = False
252
+
253
+ # Parse the tiles in the meld
254
+ tiles = self._parse_tiles(tile_notation)
255
+
256
+ if not tiles:
257
+ continue
258
+
259
+ # Determine meld type
260
+ is_closed = open_bracket == "[" and close_bracket == "]"
261
+ tile_count = len(tiles)
262
+
263
+ if is_added_kan and tile_count == 4:
264
+ meld_type = MeldType.KAN_ADDED
265
+ elif is_closed and tile_count == 4:
266
+ meld_type = MeldType.KAN_CLOSED
267
+ elif tile_count == 4:
268
+ meld_type = MeldType.KAN_OPEN
269
+ elif tile_count == 3:
270
+ # Check if it's a sequence (chi) or triplet (pon)
271
+ if self._is_sequence(tiles):
272
+ meld_type = MeldType.CHI
273
+ else:
274
+ meld_type = MeldType.PON
275
+ else:
276
+ self.errors.append(f"Invalid meld size: {tile_count} tiles")
277
+ continue
278
+
279
+ # Determine source
280
+ source = MeldSource.SELF
281
+ if source_char == "<":
282
+ source = MeldSource.LEFT
283
+ elif source_char == "^":
284
+ source = MeldSource.ACROSS
285
+ elif source_char == ">":
286
+ source = MeldSource.RIGHT
287
+
288
+ # Mark the called tile(s) as rotated based on source direction
289
+ # Position indicates which player the tile was called from:
290
+ # < (left/kamicha): rotated tile on the left (index 0)
291
+ # ^ (across/toimen): rotated tile in the middle (index 1)
292
+ # > (right/shimocha): rotated tile on the right (index 2 for 3-tile melds, index 3 for open kan)
293
+ if source != MeldSource.SELF and tiles:
294
+ # Determine rotated tile index based on source
295
+ if source == MeldSource.LEFT:
296
+ rotated_idx = 0
297
+ elif source == MeldSource.ACROSS:
298
+ rotated_idx = 1
299
+ elif meld_type == MeldType.KAN_OPEN:
300
+ # Open kan has 4 tiles, rotated tile at index 3 for RIGHT
301
+ rotated_idx = 3
302
+ else:
303
+ # Chi, pon, added kan: rotated tile at index 2 for RIGHT
304
+ rotated_idx = 2
305
+
306
+ tiles[rotated_idx].is_rotated = True
307
+
308
+ # Added kan: also mark the last tile as rotated and added
309
+ if meld_type == MeldType.KAN_ADDED:
310
+ tiles[-1].is_rotated = True
311
+ tiles[-1].is_added = True
312
+
313
+ melds.append(Meld(tiles=tiles, meld_type=meld_type, source=source))
314
+
315
+ return melds
316
+
317
+ def _is_sequence(self, tiles: list[Tile]) -> bool:
318
+ """Check if tiles form a sequence (chi).
319
+
320
+ Args:
321
+ tiles: List of 3 tiles
322
+
323
+ Returns:
324
+ True if tiles form a consecutive sequence
325
+ """
326
+ if len(tiles) != 3:
327
+ return False
328
+
329
+ # All tiles must be same suit and not honors
330
+ suits = {t.suit for t in tiles}
331
+ if len(suits) != 1 or "z" in suits:
332
+ return False
333
+
334
+ # Check for consecutive numbers (red dora 0 is treated as 5)
335
+ numbers = sorted(5 if t.number == 0 else t.number for t in tiles)
336
+
337
+ return numbers[1] == numbers[0] + 1 and numbers[2] == numbers[1] + 1
338
+
339
+
340
+ def parse_hand(notation: str) -> Hand:
341
+ """Convenience function to parse a hand notation.
342
+
343
+ Args:
344
+ notation: MPSZ notation string
345
+
346
+ Returns:
347
+ Parsed Hand object
348
+ """
349
+ parser = MahjongParser()
350
+ return parser.parse(notation)
351
+
352
+
353
+ def parse_tiles(notation: str) -> list[Tile]:
354
+ """Convenience function to parse tile notation.
355
+
356
+ Args:
357
+ notation: MPSZ notation string
358
+
359
+ Returns:
360
+ List of Tile objects
361
+ """
362
+ parser = MahjongParser()
363
+ return parser.parse_tiles(notation)