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.
- pymdownx_mahjong/__init__.py +23 -0
- pymdownx_mahjong/assets/dark/0m.svg +333 -0
- pymdownx_mahjong/assets/dark/0p.svg +491 -0
- pymdownx_mahjong/assets/dark/0s.svg +678 -0
- pymdownx_mahjong/assets/dark/1m.svg +273 -0
- pymdownx_mahjong/assets/dark/1p.svg +551 -0
- pymdownx_mahjong/assets/dark/1s.svg +764 -0
- pymdownx_mahjong/assets/dark/1z.svg +242 -0
- pymdownx_mahjong/assets/dark/2m.svg +283 -0
- pymdownx_mahjong/assets/dark/2p.svg +396 -0
- pymdownx_mahjong/assets/dark/2s.svg +369 -0
- pymdownx_mahjong/assets/dark/2z.svg +270 -0
- pymdownx_mahjong/assets/dark/3m.svg +289 -0
- pymdownx_mahjong/assets/dark/3p.svg +356 -0
- pymdownx_mahjong/assets/dark/3s.svg +462 -0
- pymdownx_mahjong/assets/dark/3z.svg +248 -0
- pymdownx_mahjong/assets/dark/4m.svg +289 -0
- pymdownx_mahjong/assets/dark/4p.svg +403 -0
- pymdownx_mahjong/assets/dark/4s.svg +570 -0
- pymdownx_mahjong/assets/dark/4z.svg +236 -0
- pymdownx_mahjong/assets/dark/5m.svg +313 -0
- pymdownx_mahjong/assets/dark/5p.svg +450 -0
- pymdownx_mahjong/assets/dark/5s.svg +608 -0
- pymdownx_mahjong/assets/dark/5z.svg +214 -0
- pymdownx_mahjong/assets/dark/6m.svg +294 -0
- pymdownx_mahjong/assets/dark/6p.svg +473 -0
- pymdownx_mahjong/assets/dark/6s.svg +695 -0
- pymdownx_mahjong/assets/dark/6z.svg +324 -0
- pymdownx_mahjong/assets/dark/7m.svg +283 -0
- pymdownx_mahjong/assets/dark/7p.svg +516 -0
- pymdownx_mahjong/assets/dark/7s.svg +628 -0
- pymdownx_mahjong/assets/dark/7z.svg +232 -0
- pymdownx_mahjong/assets/dark/8m.svg +283 -0
- pymdownx_mahjong/assets/dark/8p.svg +566 -0
- pymdownx_mahjong/assets/dark/8s.svg +712 -0
- pymdownx_mahjong/assets/dark/9m.svg +289 -0
- pymdownx_mahjong/assets/dark/9p.svg +609 -0
- pymdownx_mahjong/assets/dark/9s.svg +762 -0
- pymdownx_mahjong/assets/dark/back.svg +285 -0
- pymdownx_mahjong/assets/dark/blank.svg +225 -0
- pymdownx_mahjong/assets/dark/front.svg +334 -0
- pymdownx_mahjong/assets/light/0m.svg +319 -0
- pymdownx_mahjong/assets/light/0p.svg +460 -0
- pymdownx_mahjong/assets/light/0s.svg +614 -0
- pymdownx_mahjong/assets/light/1m.svg +276 -0
- pymdownx_mahjong/assets/light/1p.svg +544 -0
- pymdownx_mahjong/assets/light/1s.svg +764 -0
- pymdownx_mahjong/assets/light/1z.svg +242 -0
- pymdownx_mahjong/assets/light/2m.svg +286 -0
- pymdownx_mahjong/assets/light/2p.svg +400 -0
- pymdownx_mahjong/assets/light/2s.svg +372 -0
- pymdownx_mahjong/assets/light/2z.svg +270 -0
- pymdownx_mahjong/assets/light/3m.svg +292 -0
- pymdownx_mahjong/assets/light/3p.svg +362 -0
- pymdownx_mahjong/assets/light/3s.svg +462 -0
- pymdownx_mahjong/assets/light/3z.svg +248 -0
- pymdownx_mahjong/assets/light/4m.svg +292 -0
- pymdownx_mahjong/assets/light/4p.svg +407 -0
- pymdownx_mahjong/assets/light/4s.svg +573 -0
- pymdownx_mahjong/assets/light/4z.svg +236 -0
- pymdownx_mahjong/assets/light/5m.svg +313 -0
- pymdownx_mahjong/assets/light/5p.svg +454 -0
- pymdownx_mahjong/assets/light/5s.svg +608 -0
- pymdownx_mahjong/assets/light/5z.svg +214 -0
- pymdownx_mahjong/assets/light/6m.svg +297 -0
- pymdownx_mahjong/assets/light/6p.svg +477 -0
- pymdownx_mahjong/assets/light/6s.svg +688 -0
- pymdownx_mahjong/assets/light/6z.svg +309 -0
- pymdownx_mahjong/assets/light/7m.svg +283 -0
- pymdownx_mahjong/assets/light/7p.svg +567 -0
- pymdownx_mahjong/assets/light/7s.svg +628 -0
- pymdownx_mahjong/assets/light/7z.svg +232 -0
- pymdownx_mahjong/assets/light/8m.svg +286 -0
- pymdownx_mahjong/assets/light/8p.svg +570 -0
- pymdownx_mahjong/assets/light/8s.svg +712 -0
- pymdownx_mahjong/assets/light/9m.svg +292 -0
- pymdownx_mahjong/assets/light/9p.svg +623 -0
- pymdownx_mahjong/assets/light/9s.svg +748 -0
- pymdownx_mahjong/assets/light/back.svg +285 -0
- pymdownx_mahjong/assets/light/blank.svg +224 -0
- pymdownx_mahjong/assets/light/front.svg +334 -0
- pymdownx_mahjong/css/mahjong.css +416 -0
- pymdownx_mahjong/extension.py +206 -0
- pymdownx_mahjong/inline.py +78 -0
- pymdownx_mahjong/parser.py +363 -0
- pymdownx_mahjong/renderer.py +468 -0
- pymdownx_mahjong/superfences.py +126 -0
- pymdownx_mahjong/tiles.py +85 -0
- pymdownx_mahjong/utils.py +90 -0
- pymdownx_mahjong-1.0.0.dist-info/METADATA +44 -0
- pymdownx_mahjong-1.0.0.dist-info/RECORD +94 -0
- pymdownx_mahjong-1.0.0.dist-info/WHEEL +4 -0
- pymdownx_mahjong-1.0.0.dist-info/entry_points.txt +2 -0
- 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)
|