jansou 0.1.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.
- jansou/__init__.py +11 -0
- jansou/analysis/__init__.py +7 -0
- jansou/analysis/decompose.py +347 -0
- jansou/analysis/efficiency.py +202 -0
- jansou/analysis/shanten.py +188 -0
- jansou/analysis/waits.py +82 -0
- jansou/core/__init__.py +7 -0
- jansou/core/hand.py +214 -0
- jansou/core/notation.py +236 -0
- jansou/core/rules.py +327 -0
- jansou/core/tiles.py +360 -0
- jansou/game/__init__.py +11 -0
- jansou/game/actions.py +532 -0
- jansou/game/agents.py +353 -0
- jansou/game/environment.py +150 -0
- jansou/game/events.py +218 -0
- jansou/game/flow.py +886 -0
- jansou/game/progression.py +159 -0
- jansou/game/state.py +146 -0
- jansou/game/wall.py +164 -0
- jansou/io/__init__.py +8 -0
- jansou/io/from_game.py +200 -0
- jansou/io/mjai.py +334 -0
- jansou/io/mjlog.py +446 -0
- jansou/io/paifu.py +443 -0
- jansou/io/tenhou_json.py +514 -0
- jansou/io/tiles.py +110 -0
- jansou/py.typed +0 -0
- jansou/scoring/__init__.py +7 -0
- jansou/scoring/context.py +80 -0
- jansou/scoring/fu.py +194 -0
- jansou/scoring/score.py +278 -0
- jansou/scoring/yaku.py +436 -0
- jansou/validation/__init__.py +5 -0
- jansou/validation/check.py +101 -0
- jansou/validation/cli.py +272 -0
- jansou-0.1.0.dist-info/METADATA +161 -0
- jansou-0.1.0.dist-info/RECORD +41 -0
- jansou-0.1.0.dist-info/WHEEL +4 -0
- jansou-0.1.0.dist-info/entry_points.txt +2 -0
- jansou-0.1.0.dist-info/licenses/LICENSE +21 -0
jansou/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Jansou: a Riichi Mahjong library.
|
|
2
|
+
|
|
3
|
+
Functionality lives in topical subpackages:
|
|
4
|
+
|
|
5
|
+
- jansou.core: tiles, notation, rules, and hands
|
|
6
|
+
- jansou.analysis: decomposition, shanten, waits, and efficiency
|
|
7
|
+
- jansou.scoring: win context, fu, yaku, and points
|
|
8
|
+
- jansou.game: wall, state, events, actions, flow, agents, and environment
|
|
9
|
+
- jansou.io: reading, writing, and replaying external records (mjlog, Tenhou JSON, MJAI), and exporting engine games
|
|
10
|
+
- jansou.validation: checking parsed games against the scores their logs recorded
|
|
11
|
+
"""
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Hand-shape analysis.
|
|
2
|
+
|
|
3
|
+
- jansou.analysis.decompose: complete-hand decomposition and wait shapes
|
|
4
|
+
- jansou.analysis.shanten: distance to ready
|
|
5
|
+
- jansou.analysis.waits: the tiles that complete a ready hand
|
|
6
|
+
- jansou.analysis.efficiency: acceptance, discard evaluation, improvement tiles
|
|
7
|
+
"""
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Hand decomposition: every valid reading of a complete hand, with wait shapes.
|
|
2
|
+
|
|
3
|
+
A complete hand takes one of three finished shapes -- the standard four groups
|
|
4
|
+
and a pair, seven pairs, or thirteen orphans. Decomposition returns every valid
|
|
5
|
+
reading across all three, records how the winning tile fits (its wait shape),
|
|
6
|
+
and doubles as the completeness test: a hand is complete exactly when it has at
|
|
7
|
+
least one valid decomposition.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import Enum, auto, unique
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from jansou.core.hand import MeldType
|
|
17
|
+
from jansou.core.tiles import NUM_KINDS, YAOCHUU_KINDS, Tile, TileKind, counts_by_kind
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
|
|
22
|
+
from jansou.core.hand import Meld
|
|
23
|
+
|
|
24
|
+
_PAIR_SIZE = 2
|
|
25
|
+
_SET_SIZE = 3
|
|
26
|
+
_MAX_SETS = 4
|
|
27
|
+
_COMPLETE_SIZE = 14 # tiles in a complete concealed hand with no melds
|
|
28
|
+
_CHIITOI_PAIRS = 7
|
|
29
|
+
_SUITED_KINDS = 27
|
|
30
|
+
_LAST_RUN_START = 6 # a run may start at ranks 1-7, i.e. index i with i % 9 <= 6
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@unique
|
|
34
|
+
class Shape(Enum):
|
|
35
|
+
"""A finished-hand shape."""
|
|
36
|
+
|
|
37
|
+
STANDARD = auto()
|
|
38
|
+
CHIITOI = auto()
|
|
39
|
+
KOKUSHI = auto()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@unique
|
|
43
|
+
class GroupType(Enum):
|
|
44
|
+
"""The kind of a single group within a decomposition."""
|
|
45
|
+
|
|
46
|
+
RUN = auto()
|
|
47
|
+
TRIPLET = auto()
|
|
48
|
+
QUAD = auto()
|
|
49
|
+
PAIR = auto()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@unique
|
|
53
|
+
class WaitShape(Enum):
|
|
54
|
+
"""How the winning tile completes its group."""
|
|
55
|
+
|
|
56
|
+
RYANMEN = auto() # two-sided run partial
|
|
57
|
+
KANCHAN = auto() # closed (middle) run partial
|
|
58
|
+
PENCHAN = auto() # edge run partial
|
|
59
|
+
SHANPON = auto() # dual pair
|
|
60
|
+
TANKI = auto() # pair wait
|
|
61
|
+
CHIITOI = auto() # the seventh pair of seven pairs
|
|
62
|
+
KOKUSHI = auto() # the missing kind of a single-wait thirteen orphans
|
|
63
|
+
KOKUSHI_THIRTEEN = auto() # any of the thirteen, a thirteen orphans thirteen-wait
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Group:
|
|
68
|
+
"""One group of a decomposition: a run, triplet, quad, or pair.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
type: The kind of group -- run, triplet, quad, or pair.
|
|
72
|
+
kinds: The group's tile kinds in ascending order.
|
|
73
|
+
concealed: ``True`` for a group formed from concealed tiles or a closed
|
|
74
|
+
kan, ``False`` for a group exposed by a call.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
type: GroupType
|
|
78
|
+
kinds: tuple[TileKind, ...]
|
|
79
|
+
concealed: bool
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def kind(self) -> TileKind:
|
|
83
|
+
"""The group's defining kind: the low tile of a run, else the repeated tile."""
|
|
84
|
+
return self.kinds[0]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Decomposition:
|
|
89
|
+
"""One valid reading of a complete hand.
|
|
90
|
+
|
|
91
|
+
For the standard shape, ``groups`` holds the four sets (melds and concealed
|
|
92
|
+
groups together) and ``pair`` the head. For seven pairs, ``groups`` holds the
|
|
93
|
+
seven pairs and ``pair`` is ``None``. For thirteen orphans, ``groups`` is empty.
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
shape: The finished-hand shape this reading takes.
|
|
97
|
+
groups: The sets of the reading, excluding the head pair.
|
|
98
|
+
pair: The head pair, or ``None`` for seven pairs and thirteen orphans.
|
|
99
|
+
wait: How the winning tile completes its group.
|
|
100
|
+
winning_tile: The tile that completed the hand.
|
|
101
|
+
winning_group: The block the winning tile completed, or ``None`` for
|
|
102
|
+
thirteen orphans.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
shape: Shape
|
|
106
|
+
groups: tuple[Group, ...]
|
|
107
|
+
pair: Group | None
|
|
108
|
+
wait: WaitShape
|
|
109
|
+
winning_tile: Tile
|
|
110
|
+
winning_group: Group | None
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def all_groups(self) -> tuple[Group, ...]:
|
|
114
|
+
"""Every group including the head pair, for whole-hand scans."""
|
|
115
|
+
return (*self.groups, self.pair) if self.pair is not None else self.groups
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _meld_group(meld: Meld) -> Group:
|
|
119
|
+
"""The fixed group a called meld contributes."""
|
|
120
|
+
kinds = tuple(sorted(tile.kind for tile in meld.tiles))
|
|
121
|
+
group_type = {
|
|
122
|
+
MeldType.CHII: GroupType.RUN,
|
|
123
|
+
MeldType.PON: GroupType.TRIPLET,
|
|
124
|
+
MeldType.DAIMINKAN: GroupType.QUAD,
|
|
125
|
+
MeldType.ANKAN: GroupType.QUAD,
|
|
126
|
+
MeldType.SHOUMINKAN: GroupType.QUAD,
|
|
127
|
+
}[meld.type]
|
|
128
|
+
return Group(group_type, kinds, concealed=meld.type is MeldType.ANKAN)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- Completeness tests -----------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _standard_complete(counts: list[int], need_sets: int) -> bool:
|
|
135
|
+
"""Whether the counts form exactly `need_sets` sets plus one pair.
|
|
136
|
+
|
|
137
|
+
The caller guarantees the tile count matches; the recursion consumes every
|
|
138
|
+
tile, so a mismatched count simply fails to complete.
|
|
139
|
+
"""
|
|
140
|
+
return _consume(counts, need_sets, pair_used=False)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _consume(counts: list[int], sets_left: int, *, pair_used: bool) -> bool:
|
|
144
|
+
"""Recursively peel a pair and sets from the lowest occupied kind."""
|
|
145
|
+
i = next((k for k in range(NUM_KINDS) if counts[k] > 0), None)
|
|
146
|
+
if i is None:
|
|
147
|
+
return sets_left == 0 and pair_used
|
|
148
|
+
if not pair_used and counts[i] >= _PAIR_SIZE:
|
|
149
|
+
counts[i] -= _PAIR_SIZE
|
|
150
|
+
if _consume(counts, sets_left, pair_used=True):
|
|
151
|
+
counts[i] += _PAIR_SIZE
|
|
152
|
+
return True
|
|
153
|
+
counts[i] += _PAIR_SIZE
|
|
154
|
+
if sets_left > 0:
|
|
155
|
+
if counts[i] >= _SET_SIZE:
|
|
156
|
+
counts[i] -= _SET_SIZE
|
|
157
|
+
if _consume(counts, sets_left - 1, pair_used=pair_used):
|
|
158
|
+
counts[i] += _SET_SIZE
|
|
159
|
+
return True
|
|
160
|
+
counts[i] += _SET_SIZE
|
|
161
|
+
if _can_run(counts, i):
|
|
162
|
+
_take_run(counts, i, -1)
|
|
163
|
+
if _consume(counts, sets_left - 1, pair_used=pair_used):
|
|
164
|
+
_take_run(counts, i, +1)
|
|
165
|
+
return True
|
|
166
|
+
_take_run(counts, i, +1)
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _can_run(counts: list[int], i: int) -> bool:
|
|
171
|
+
"""Whether a run can start at kind index i given the counts."""
|
|
172
|
+
return i < _SUITED_KINDS and i % 9 <= _LAST_RUN_START and counts[i + 1] > 0 and counts[i + 2] > 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _take_run(counts: list[int], i: int, sign: int) -> None:
|
|
176
|
+
"""Add `sign` to each of the three ranks of the run starting at i."""
|
|
177
|
+
counts[i] += sign
|
|
178
|
+
counts[i + 1] += sign
|
|
179
|
+
counts[i + 2] += sign
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _chiitoi_complete(counts: list[int]) -> bool:
|
|
183
|
+
"""Whether the counts are seven distinct pairs."""
|
|
184
|
+
return sum(1 for c in counts if c == _PAIR_SIZE) == _CHIITOI_PAIRS and all(c in (0, _PAIR_SIZE) for c in counts)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _kokushi_complete(counts: list[int]) -> bool:
|
|
188
|
+
"""Whether the counts are one of each terminal-or-honor plus a duplicate."""
|
|
189
|
+
if any(counts[k] for k in range(NUM_KINDS) if TileKind(k) not in YAOCHUU_KINDS):
|
|
190
|
+
return False
|
|
191
|
+
present = [counts[k] for k in YAOCHUU_KINDS]
|
|
192
|
+
return all(present) and sum(present) == len(YAOCHUU_KINDS) + 1
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def is_complete(counts: list[int], num_melds: int, *, allow_special: bool | None = None) -> bool:
|
|
196
|
+
"""Whether the concealed counts complete the hand given the meld count.
|
|
197
|
+
|
|
198
|
+
Seven pairs and thirteen orphans apply only to a full concealed hand; by
|
|
199
|
+
default they are considered exactly when there are no melds. Pass
|
|
200
|
+
``allow_special`` to force the choice.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
counts: Concealed tile counts indexed by kind, including the winning tile.
|
|
204
|
+
num_melds: The number of called melds the hand holds.
|
|
205
|
+
allow_special: Whether to consider the seven-pairs and thirteen-orphans
|
|
206
|
+
shapes. Defaults to ``None``, which enables them exactly when there
|
|
207
|
+
are no melds.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
``True`` if the counts complete the hand under any applicable shape.
|
|
211
|
+
"""
|
|
212
|
+
total = sum(counts)
|
|
213
|
+
special = (num_melds == 0) if allow_special is None else allow_special
|
|
214
|
+
if total == (_MAX_SETS - num_melds) * _SET_SIZE + _PAIR_SIZE and _standard_complete(counts, _MAX_SETS - num_melds):
|
|
215
|
+
return True
|
|
216
|
+
return bool(special and total == _COMPLETE_SIZE and (_chiitoi_complete(counts) or _kokushi_complete(counts)))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --- Full enumeration -------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _all_set_partitions(counts: list[int], need_sets: int) -> list[tuple[tuple[str, int], ...]]:
|
|
223
|
+
"""Every way to partition the counts fully into `need_sets` sets.
|
|
224
|
+
|
|
225
|
+
Each set is encoded as ('run', low_index) or ('trip', index).
|
|
226
|
+
"""
|
|
227
|
+
i = next((k for k in range(NUM_KINDS) if counts[k] > 0), None)
|
|
228
|
+
if i is None:
|
|
229
|
+
return [()] if need_sets == 0 else []
|
|
230
|
+
if need_sets == 0:
|
|
231
|
+
return []
|
|
232
|
+
out: list[tuple[tuple[str, int], ...]] = []
|
|
233
|
+
if counts[i] >= _SET_SIZE:
|
|
234
|
+
counts[i] -= _SET_SIZE
|
|
235
|
+
out.extend((("trip", i), *rest) for rest in _all_set_partitions(counts, need_sets - 1))
|
|
236
|
+
counts[i] += _SET_SIZE
|
|
237
|
+
if _can_run(counts, i):
|
|
238
|
+
_take_run(counts, i, -1)
|
|
239
|
+
out.extend((("run", i), *rest) for rest in _all_set_partitions(counts, need_sets - 1))
|
|
240
|
+
_take_run(counts, i, +1)
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _run_wait(run_kinds: tuple[TileKind, ...], won: TileKind) -> WaitShape:
|
|
245
|
+
"""The wait shape when the winning tile completes a run."""
|
|
246
|
+
low, mid, high = (k.rank for k in run_kinds)
|
|
247
|
+
if won.rank == mid:
|
|
248
|
+
return WaitShape.KANCHAN
|
|
249
|
+
partial = {r for r in (low, mid, high) if r != won.rank}
|
|
250
|
+
if partial in ({1, 2}, {8, 9}):
|
|
251
|
+
return WaitShape.PENCHAN
|
|
252
|
+
return WaitShape.RYANMEN
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _standard_decompositions(counts: list[int], melds: tuple[Meld, ...], winning: Tile) -> list[Decomposition]:
|
|
256
|
+
"""Every standard-shape reading of the concealed counts around the melds."""
|
|
257
|
+
need_sets = _MAX_SETS - len(melds)
|
|
258
|
+
if sum(counts) != need_sets * _SET_SIZE + _PAIR_SIZE:
|
|
259
|
+
return []
|
|
260
|
+
meld_groups = tuple(_meld_group(meld) for meld in melds)
|
|
261
|
+
seen: set[tuple[object, ...]] = set()
|
|
262
|
+
out: list[Decomposition] = []
|
|
263
|
+
|
|
264
|
+
def emit(concealed_sets: tuple[Group, ...], pair: Group) -> None:
|
|
265
|
+
# One reading per concealed block the winning tile can complete.
|
|
266
|
+
groups = meld_groups + concealed_sets
|
|
267
|
+
for block in (*concealed_sets, pair):
|
|
268
|
+
if winning.kind not in block.kinds:
|
|
269
|
+
continue
|
|
270
|
+
wait = _block_wait(block, winning.kind)
|
|
271
|
+
key = (tuple(sorted(groups, key=_group_key)), pair, wait)
|
|
272
|
+
if key in seen:
|
|
273
|
+
continue
|
|
274
|
+
seen.add(key)
|
|
275
|
+
out.append(Decomposition(Shape.STANDARD, groups, pair, wait, winning, block))
|
|
276
|
+
|
|
277
|
+
for pair_kind in range(NUM_KINDS):
|
|
278
|
+
if counts[pair_kind] < _PAIR_SIZE:
|
|
279
|
+
continue
|
|
280
|
+
counts[pair_kind] -= _PAIR_SIZE
|
|
281
|
+
pair = Group(GroupType.PAIR, (TileKind(pair_kind), TileKind(pair_kind)), concealed=True)
|
|
282
|
+
for partition in _all_set_partitions(counts, need_sets):
|
|
283
|
+
emit(tuple(_partition_group(entry) for entry in partition), pair)
|
|
284
|
+
counts[pair_kind] += _PAIR_SIZE
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _partition_group(entry: tuple[str, int]) -> Group:
|
|
289
|
+
"""The concealed Group for an encoded set."""
|
|
290
|
+
label, i = entry
|
|
291
|
+
if label == "trip":
|
|
292
|
+
kind = TileKind(i)
|
|
293
|
+
return Group(GroupType.TRIPLET, (kind, kind, kind), concealed=True)
|
|
294
|
+
return Group(GroupType.RUN, (TileKind(i), TileKind(i + 1), TileKind(i + 2)), concealed=True)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _block_wait(block: Group, won: TileKind) -> WaitShape:
|
|
298
|
+
"""The wait shape when the winning tile completes the given concealed block."""
|
|
299
|
+
if block.type is GroupType.PAIR:
|
|
300
|
+
return WaitShape.TANKI
|
|
301
|
+
if block.type is GroupType.TRIPLET:
|
|
302
|
+
return WaitShape.SHANPON
|
|
303
|
+
return _run_wait(block.kinds, won)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _group_key(group: Group) -> tuple[int, tuple[int, ...], bool]:
|
|
307
|
+
"""A sortable, hashable key for a group."""
|
|
308
|
+
return (group.type.value, tuple(group.kinds), group.concealed)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _special_decompositions(counts: list[int], winning: Tile) -> list[Decomposition]:
|
|
312
|
+
"""The seven-pairs and thirteen-orphans readings, if either applies."""
|
|
313
|
+
out: list[Decomposition] = []
|
|
314
|
+
if _chiitoi_complete(counts):
|
|
315
|
+
pairs = tuple(
|
|
316
|
+
Group(GroupType.PAIR, (TileKind(k), TileKind(k)), concealed=True) for k in range(NUM_KINDS) if counts[k]
|
|
317
|
+
)
|
|
318
|
+
won_pair = next(group for group in pairs if group.kind == winning.kind)
|
|
319
|
+
out.append(Decomposition(Shape.CHIITOI, pairs, None, WaitShape.CHIITOI, winning, won_pair))
|
|
320
|
+
if _kokushi_complete(counts):
|
|
321
|
+
thirteen = counts[winning.kind] == _PAIR_SIZE
|
|
322
|
+
wait = WaitShape.KOKUSHI_THIRTEEN if thirteen else WaitShape.KOKUSHI
|
|
323
|
+
out.append(Decomposition(Shape.KOKUSHI, (), None, wait, winning, None))
|
|
324
|
+
return out
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def decompose(concealed: Iterable[Tile], melds: tuple[Meld, ...], winning_tile: Tile) -> list[Decomposition]:
|
|
328
|
+
"""Every valid reading of the complete hand across all three shapes.
|
|
329
|
+
|
|
330
|
+
``concealed`` includes the winning tile. An incomplete hand yields an empty
|
|
331
|
+
list. Readings identical in their groups, pair, and wait shape are
|
|
332
|
+
collapsed to one.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
concealed: The concealed tiles, including the winning tile.
|
|
336
|
+
melds: The called melds, each contributing one fixed group.
|
|
337
|
+
winning_tile: The tile that completed the hand.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Every distinct reading of the hand; empty if the hand is not complete.
|
|
341
|
+
"""
|
|
342
|
+
concealed = tuple(concealed)
|
|
343
|
+
counts = counts_by_kind(concealed)
|
|
344
|
+
results = _standard_decompositions(counts, melds, winning_tile)
|
|
345
|
+
if not melds and sum(counts) == _COMPLETE_SIZE:
|
|
346
|
+
results.extend(_special_decompositions(counts, winning_tile))
|
|
347
|
+
return results
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Tile efficiency: acceptance, discard evaluation, and improvement tiles.
|
|
2
|
+
|
|
3
|
+
A shape-and-count analysis built on shanten: a draw is useful when it lowers
|
|
4
|
+
shanten, and a discard is judged by the shanten and acceptance it leaves
|
|
5
|
+
behind. It weighs neither yaku nor value nor safety, only the distance to ready
|
|
6
|
+
and the count of tiles that shorten it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from jansou.analysis.shanten import shanten_counts
|
|
15
|
+
from jansou.core.tiles import TILES_PER_KIND, TileKind, counts_by_kind, kinds_in_play
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
|
|
20
|
+
from jansou.core.hand import Hand
|
|
21
|
+
|
|
22
|
+
_POST_DRAW_SIZES = (2, 5, 8, 11, 14)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class DiscardOption:
|
|
27
|
+
"""The outcome of discarding one kind from a post-draw hand.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
discard: The kind discarded.
|
|
31
|
+
shanten: The shanten of the hand left behind.
|
|
32
|
+
acceptance: The accepting kinds of the resulting hand, each mapped to the
|
|
33
|
+
copies still available to draw.
|
|
34
|
+
total_acceptance: The total accepting draws, the sum of ``acceptance``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
discard: TileKind
|
|
38
|
+
shanten: int
|
|
39
|
+
acceptance: Mapping[TileKind, int]
|
|
40
|
+
total_acceptance: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _remaining(kind: TileKind, hand_counts: list[int], visible: list[int]) -> int:
|
|
44
|
+
"""How many copies of a kind are still available to draw."""
|
|
45
|
+
return TILES_PER_KIND - hand_counts[kind] - visible[kind]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def acceptance_counts(
|
|
49
|
+
concealed: list[int],
|
|
50
|
+
num_melds: int,
|
|
51
|
+
*,
|
|
52
|
+
visible: list[int] | None = None,
|
|
53
|
+
player_count: int = 4,
|
|
54
|
+
) -> dict[TileKind, int]:
|
|
55
|
+
"""Kinds that strictly lower the hand's shanten, each with copies remaining.
|
|
56
|
+
|
|
57
|
+
``visible`` counts tiles known to be outside the hand -- discards, melds, and
|
|
58
|
+
revealed indicators -- which are deducted from the remaining copies. A kind
|
|
59
|
+
with no copies left is not accepted.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
concealed: Concealed tile counts indexed by kind.
|
|
63
|
+
num_melds: The number of called melds the hand holds.
|
|
64
|
+
visible: Counts by kind of tiles known to be outside the hand. Defaults
|
|
65
|
+
to none visible.
|
|
66
|
+
player_count: The number of players, which fixes the kinds in play.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Each accepting kind mapped to the copies still available to draw.
|
|
70
|
+
"""
|
|
71
|
+
visible = visible or [0] * len(concealed)
|
|
72
|
+
base = shanten_counts(concealed, num_melds)
|
|
73
|
+
result: dict[TileKind, int] = {}
|
|
74
|
+
for kind in kinds_in_play(player_count):
|
|
75
|
+
remaining = _remaining(kind, concealed, visible)
|
|
76
|
+
if remaining <= 0:
|
|
77
|
+
continue
|
|
78
|
+
concealed[kind] += 1
|
|
79
|
+
improved = shanten_counts(concealed, num_melds) < base
|
|
80
|
+
concealed[kind] -= 1
|
|
81
|
+
if improved:
|
|
82
|
+
result[kind] = remaining
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def acceptance(hand: Hand, *, visible: list[int] | None = None, player_count: int = 4) -> dict[TileKind, int]:
|
|
87
|
+
"""The acceptance of a resting hand: the kinds that bring it closer to ready.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
hand: The resting hand to analyse.
|
|
91
|
+
visible: Counts by kind of tiles known to be outside the hand. Defaults
|
|
92
|
+
to none visible.
|
|
93
|
+
player_count: The number of players, which fixes the kinds in play.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Each accepting kind mapped to the copies still available to draw.
|
|
97
|
+
"""
|
|
98
|
+
return acceptance_counts(
|
|
99
|
+
counts_by_kind(hand.concealed),
|
|
100
|
+
len(hand.melds),
|
|
101
|
+
visible=visible,
|
|
102
|
+
player_count=player_count,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def discard_evaluation(
|
|
107
|
+
hand: Hand,
|
|
108
|
+
*,
|
|
109
|
+
visible: list[int] | None = None,
|
|
110
|
+
player_count: int = 4,
|
|
111
|
+
) -> list[DiscardOption]:
|
|
112
|
+
"""Every distinct discard from a post-draw hand, most efficient first.
|
|
113
|
+
|
|
114
|
+
Each option reports the resulting shanten, the acceptance of the resulting
|
|
115
|
+
hand, and the total accepting draws. Options are ordered by resulting shanten
|
|
116
|
+
ascending first -- a discard that stays closer to ready always ranks higher --
|
|
117
|
+
then by total acceptance descending, ties broken by the discard kind in
|
|
118
|
+
ascending canonical order.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
hand: The post-draw hand to evaluate.
|
|
122
|
+
visible: Counts by kind of tiles known to be outside the hand. Defaults
|
|
123
|
+
to none visible.
|
|
124
|
+
player_count: The number of players, which fixes the kinds in play.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
One option per distinct discardable kind, most efficient first.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ValueError: If the hand is not at a post-draw tile count.
|
|
131
|
+
"""
|
|
132
|
+
concealed = counts_by_kind(hand.concealed)
|
|
133
|
+
num_melds = len(hand.melds)
|
|
134
|
+
if sum(concealed) not in _POST_DRAW_SIZES:
|
|
135
|
+
raise ValueError(f"discard evaluation needs a post-draw hand, got {sum(concealed)} concealed tiles")
|
|
136
|
+
options: list[DiscardOption] = []
|
|
137
|
+
for kind in kinds_in_play(player_count):
|
|
138
|
+
if concealed[kind] == 0:
|
|
139
|
+
continue
|
|
140
|
+
concealed[kind] -= 1
|
|
141
|
+
result_shanten = shanten_counts(concealed, num_melds)
|
|
142
|
+
accepts = acceptance_counts(concealed, num_melds, visible=visible, player_count=player_count)
|
|
143
|
+
concealed[kind] += 1
|
|
144
|
+
options.append(DiscardOption(kind, result_shanten, accepts, sum(accepts.values())))
|
|
145
|
+
options.sort(key=lambda option: (option.shanten, -option.total_acceptance, option.discard))
|
|
146
|
+
return options
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def improvements(
|
|
150
|
+
hand: Hand,
|
|
151
|
+
*,
|
|
152
|
+
visible: list[int] | None = None,
|
|
153
|
+
player_count: int = 4,
|
|
154
|
+
) -> dict[TileKind, dict[TileKind, int]]:
|
|
155
|
+
"""Draws that keep shanten but widen acceptance, each with the wider acceptance.
|
|
156
|
+
|
|
157
|
+
An acceptance-upgrade analysis on a resting hand: for a draw that does not
|
|
158
|
+
reduce shanten, the best same-shanten discard is taken and the resulting
|
|
159
|
+
acceptance compared against the current hand's. Only strictly wider results
|
|
160
|
+
are reported.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
hand: The resting hand to analyse.
|
|
164
|
+
visible: Counts by kind of tiles known to be outside the hand. Defaults
|
|
165
|
+
to none visible.
|
|
166
|
+
player_count: The number of players, which fixes the kinds in play.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Each qualifying draw mapped to the wider acceptance it unlocks.
|
|
170
|
+
"""
|
|
171
|
+
concealed = counts_by_kind(hand.concealed)
|
|
172
|
+
num_melds = len(hand.melds)
|
|
173
|
+
base = shanten_counts(concealed, num_melds)
|
|
174
|
+
base_total = sum(acceptance_counts(concealed, num_melds, visible=visible, player_count=player_count).values())
|
|
175
|
+
|
|
176
|
+
def best_upgrade() -> dict[TileKind, int] | None:
|
|
177
|
+
# The widest same-shanten acceptance reachable by one discard, if it beats the base.
|
|
178
|
+
best_acc: dict[TileKind, int] | None = None
|
|
179
|
+
best_total = base_total
|
|
180
|
+
for discard in kinds_in_play(player_count):
|
|
181
|
+
if concealed[discard] == 0:
|
|
182
|
+
continue
|
|
183
|
+
concealed[discard] -= 1
|
|
184
|
+
if shanten_counts(concealed, num_melds) == base:
|
|
185
|
+
accepts = acceptance_counts(concealed, num_melds, visible=visible, player_count=player_count)
|
|
186
|
+
total = sum(accepts.values())
|
|
187
|
+
if total > best_total:
|
|
188
|
+
best_total, best_acc = total, accepts
|
|
189
|
+
concealed[discard] += 1
|
|
190
|
+
return best_acc
|
|
191
|
+
|
|
192
|
+
result: dict[TileKind, dict[TileKind, int]] = {}
|
|
193
|
+
for draw in kinds_in_play(player_count):
|
|
194
|
+
if _remaining(draw, concealed, visible or [0] * len(concealed)) <= 0:
|
|
195
|
+
continue
|
|
196
|
+
concealed[draw] += 1
|
|
197
|
+
if shanten_counts(concealed, num_melds) == base:
|
|
198
|
+
best = best_upgrade()
|
|
199
|
+
if best is not None:
|
|
200
|
+
result[draw] = best
|
|
201
|
+
concealed[draw] -= 1
|
|
202
|
+
return result
|