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 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