Coverage for project/game/ai/hand_builder.py : 91%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from typing import List
3import utils.decisions_constants as log
4from game.ai.discard import DiscardOption
5from game.ai.helpers.kabe import Kabe
6from mahjong.shanten import Shanten
7from mahjong.tile import Tile, TilesConverter
8from mahjong.utils import is_honor, is_pair, is_terminal, is_tile_strictly_isolated, plus_dora, simplify
9from utils.decisions_logger import MeldPrint
12class HandBuilder:
13 player = None
14 ai = None
16 def __init__(self, player, ai):
17 self.player = player
18 self.ai = ai
20 def discard_tile(self):
21 selected_tile = self.choose_tile_to_discard()
22 return self.process_discard_option(selected_tile)
24 def formal_riichi_conditions_for_discard(self, discard_option):
25 remaining_tiles = self.player.table.count_of_remaining_tiles
26 scores = self.player.scores
28 return all(
29 [
30 # <= 0 because technically riichi from agari is possible
31 discard_option.shanten <= 0,
32 not self.player.in_riichi,
33 not self.player.is_open_hand,
34 # in some tests this value may be not initialized
35 scores and scores >= 1000 or False,
36 remaining_tiles and remaining_tiles > 4 or False,
37 ]
38 )
40 def mark_tiles_riichi_decision(self, discard_options):
41 threats = self.player.ai.defence.get_threatening_players()
42 for discard_option in discard_options:
43 if not self.formal_riichi_conditions_for_discard(discard_option):
44 continue
46 if self.player.ai.riichi.should_call_riichi(discard_option, threats):
47 discard_option.with_riichi = True
49 def choose_tile_to_discard(self, after_meld=False):
50 """
51 Try to find best tile to discard, based on different evaluations
52 """
53 self._assert_hand_correctness()
55 threatening_players = None
57 discard_options, min_shanten = self.find_discard_options()
58 if min_shanten == Shanten.AGARI_STATE:
59 min_shanten = min([x.shanten for x in discard_options])
61 if not after_meld:
62 self.mark_tiles_riichi_decision(discard_options)
64 one_shanten_ukeire2_calculated_beforehand = False
65 if self.player.config.FEATURE_DEFENCE_ENABLED:
66 # FIXME: this is hacky and takes too much time! refactor
67 # we need to calculate ukeire2 beforehand for correct danger calculation
68 if self.player.ai.defence.get_threatening_players() and min_shanten != 0:
69 for discard_option in discard_options:
70 if discard_option.shanten == 1:
71 self.calculate_second_level_ukeire(discard_option, after_meld)
72 one_shanten_ukeire2_calculated_beforehand = True
74 discard_options, threatening_players = self.player.ai.defence.mark_tiles_danger_for_threats(discard_options)
76 if threatening_players:
77 self.player.logger.debug(log.DEFENCE_THREATENING_ENEMY, "Threats", context=threatening_players)
79 self.player.logger.debug(log.DISCARD_OPTIONS, "All discard candidates", discard_options)
81 tiles_we_can_discard = [x for x in discard_options if x.danger.is_danger_acceptable()]
82 if not tiles_we_can_discard:
83 # no tiles with acceptable danger - we go betaori
84 return self._choose_safest_tile_or_skip_meld(discard_options, after_meld)
86 # our strategy can affect discard options
87 if self.ai.open_hand_handler.current_strategy:
88 tiles_we_can_discard = self.ai.open_hand_handler.current_strategy.determine_what_to_discard(
89 tiles_we_can_discard, self.player.closed_hand, self.player.melds
90 )
92 had_to_be_discarded_tiles = [x for x in tiles_we_can_discard if x.had_to_be_discarded]
93 if had_to_be_discarded_tiles:
94 # we don't care about effectiveness of tiles that don't suit our strategy,
95 # so just choose the safest one
96 return self._choose_safest_tile(had_to_be_discarded_tiles)
98 # we check this after strategy checks to allow discarding safe 99 from tempai to get tanyao for example
99 min_acceptable_shanten = min([x.shanten for x in tiles_we_can_discard])
100 assert min_acceptable_shanten >= min_shanten
101 if min_acceptable_shanten > min_shanten:
102 # all tiles with acceptable danger increase number of shanten - we just go betaori
103 return self._choose_safest_tile_or_skip_meld(discard_options, after_meld)
105 # we should have some plan for push when we open our hand, otherwise - don't meld
106 if threatening_players and after_meld and min_shanten > 0:
107 if not self._find_acceptable_path_to_tempai(tiles_we_can_discard, min_shanten):
108 return None
110 # by now we have the following:
111 # - all discard candidates have acceptable danger
112 # - all discard candidates allow us to proceed with our strategy
113 tiles_we_can_discard = sorted(tiles_we_can_discard, key=lambda x: (x.shanten, -x.ukeire))
114 first_option = tiles_we_can_discard[0]
115 assert first_option.shanten == min_shanten
116 results_with_same_shanten = [x for x in tiles_we_can_discard if x.shanten == first_option.shanten]
118 if first_option.shanten == 0:
119 return self._choose_best_discard_in_tempai(results_with_same_shanten, after_meld)
121 if first_option.shanten == 1:
122 return self._choose_best_discard_with_1_shanten(
123 results_with_same_shanten, after_meld, one_shanten_ukeire2_calculated_beforehand
124 )
126 if first_option.shanten == 2 or first_option.shanten == 3:
127 return self._choose_best_discard_with_2_3_shanten(results_with_same_shanten, after_meld)
129 return self._choose_best_discard_with_4_or_more_shanten(results_with_same_shanten)
131 def process_discard_option(self, discard_option):
132 self.player.logger.debug(log.DISCARD, context=discard_option)
134 self.player.in_tempai = discard_option.shanten == 0
135 self.ai.waiting = discard_option.waiting
136 self.ai.shanten = discard_option.shanten
137 self.ai.ukeire = discard_option.ukeire
138 self.ai.ukeire_second = discard_option.ukeire_second
140 return discard_option.tile_to_discard_136, discard_option.with_riichi
142 def calculate_shanten_and_decide_hand_structure(self, closed_hand_34):
143 shanten_without_chiitoitsu = self.ai.calculate_shanten_or_get_from_cache(closed_hand_34, use_chiitoitsu=False)
145 if not self.player.is_open_hand:
146 shanten_with_chiitoitsu = self.ai.calculate_shanten_or_get_from_cache(closed_hand_34, use_chiitoitsu=True)
147 return self._decide_if_use_chiitoitsu(shanten_with_chiitoitsu, shanten_without_chiitoitsu)
148 else:
149 return shanten_without_chiitoitsu, False
151 def calculate_waits(self, closed_hand_34: List[int], all_tiles_34: List[int], use_chiitoitsu: bool = False):
152 previous_shanten = self.ai.calculate_shanten_or_get_from_cache(closed_hand_34, use_chiitoitsu=use_chiitoitsu)
154 waiting = []
155 for tile_index in range(0, 34):
156 # it is important to check that we don't have all 4 tiles in the all tiles
157 # not in the closed hand
158 # so, we will not count 1z as waiting when we have 111z meld
159 if all_tiles_34[tile_index] == 4:
160 continue
162 closed_hand_34[tile_index] += 1
164 skip_isolated_tile = True
165 if closed_hand_34[tile_index] == 4:
166 skip_isolated_tile = False
167 if use_chiitoitsu and closed_hand_34[tile_index] == 3:
168 skip_isolated_tile = False
170 # there is no need to check single isolated tile
171 if skip_isolated_tile and is_tile_strictly_isolated(closed_hand_34, tile_index):
172 closed_hand_34[tile_index] -= 1
173 continue
175 new_shanten = self.ai.calculate_shanten_or_get_from_cache(closed_hand_34, use_chiitoitsu=use_chiitoitsu)
177 if new_shanten == previous_shanten - 1:
178 waiting.append(tile_index)
180 closed_hand_34[tile_index] -= 1
182 return waiting, previous_shanten
184 def find_discard_options(self):
185 """
186 :param tiles: array of tiles in 136 format
187 :param closed_hand: array of tiles in 136 format
188 :return:
189 """
190 self._assert_hand_correctness()
192 tiles = self.player.tiles
193 closed_hand = self.player.closed_hand
195 tiles_34 = TilesConverter.to_34_array(tiles)
196 closed_tiles_34 = TilesConverter.to_34_array(closed_hand)
197 is_agari = self.ai.agari.is_agari(tiles_34, self.player.meld_34_tiles)
199 # we decide beforehand if we need to consider chiitoitsu for all of our possible discards
200 min_shanten, use_chiitoitsu = self.calculate_shanten_and_decide_hand_structure(closed_tiles_34)
202 results = []
203 tile_34_prev = None
204 # we iterate in reverse order to naturally handle aka-doras, i.e. discard regular 5 if we have it
205 for tile_136 in reversed(self.player.closed_hand):
206 tile_34 = tile_136 // 4
207 # already added
208 if tile_34 == tile_34_prev:
209 continue
210 else:
211 tile_34_prev = tile_34
213 closed_tiles_34[tile_34] -= 1
214 waiting, shanten = self.calculate_waits(closed_tiles_34, tiles_34, use_chiitoitsu=use_chiitoitsu)
215 assert shanten >= min_shanten
216 closed_tiles_34[tile_34] += 1
218 if waiting:
219 wait_to_ukeire = dict(zip(waiting, [self.count_tiles([x], closed_tiles_34) for x in waiting]))
220 results.append(
221 DiscardOption(
222 player=self.player,
223 shanten=shanten,
224 tile_to_discard_136=tile_136,
225 waiting=waiting,
226 ukeire=sum(wait_to_ukeire.values()),
227 wait_to_ukeire=wait_to_ukeire,
228 )
229 )
231 if is_agari:
232 shanten = Shanten.AGARI_STATE
233 else:
234 shanten = min_shanten
236 return results, shanten
238 def count_tiles(self, waiting, tiles_34):
239 n = 0
240 for tile_34 in waiting:
241 n += 4 - self.player.number_of_revealed_tiles(tile_34, tiles_34)
242 return n
244 def divide_hand(self, tiles, waiting):
245 tiles_copy = tiles[:]
247 for i in range(0, 4):
248 if waiting * 4 + i not in tiles_copy:
249 tiles_copy += [waiting * 4 + i]
250 break
252 tiles_34 = TilesConverter.to_34_array(tiles_copy)
254 results = self.player.ai.hand_divider.divide_hand(tiles_34, self.player.melds)
255 return results, tiles_34
257 def emulate_discard(self, discard_option):
258 player_tiles_original = self.player.tiles[:]
259 player_discards_original = self.player.discards[:]
261 tile_in_hand = discard_option.tile_to_discard_136
263 self.player.discards.append(Tile(tile_in_hand, False))
264 self.player.tiles.remove(tile_in_hand)
266 return player_tiles_original, player_discards_original
268 def restore_after_emulate_discard(self, tiles_original, discards_original):
269 self.player.tiles = tiles_original
270 self.player.discards = discards_original
272 def check_suji_and_kabe(self, tiles_34, waiting):
273 # let's find suji-traps in our discard
274 suji_tiles = self.player.ai.suji.find_suji([x.value for x in self.player.discards])
275 have_suji = waiting in suji_tiles
277 # let's find kabe
278 kabe_tiles = self.player.ai.kabe.find_all_kabe(tiles_34)
279 have_kabe = False
280 for kabe in kabe_tiles:
281 if waiting == kabe["tile"] and kabe["type"] == Kabe.STRONG_KABE:
282 have_kabe = True
283 break
285 return have_suji, have_kabe
287 def calculate_second_level_ukeire(self, discard_option, after_meld=False):
288 self._assert_hand_correctness()
290 not_suitable_tiles = (
291 self.ai.open_hand_handler.current_strategy
292 and self.ai.open_hand_handler.current_strategy.not_suitable_tiles
293 or []
294 )
295 call_riichi = discard_option.with_riichi
297 # we are going to do manipulations that require player hand and discards to be updated
298 # so we save original tiles and discards here and restore it at the end of the function
299 player_tiles_original, player_discards_original = self.emulate_discard(discard_option)
301 sum_tiles = 0
302 sum_cost = 0
303 average_costs = []
304 for wait_34 in discard_option.waiting:
305 if self.player.is_open_hand and wait_34 in not_suitable_tiles:
306 continue
308 closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand)
309 live_tiles = 4 - self.player.number_of_revealed_tiles(wait_34, closed_hand_34)
311 if live_tiles == 0:
312 continue
314 wait_136 = self._find_live_tile(wait_34)
315 assert wait_136 is not None
316 self.player.tiles.append(wait_136)
318 results, shanten = self.find_discard_options()
319 results = [x for x in results if x.shanten == discard_option.shanten - 1]
321 # let's take best ukeire here
322 if results:
323 result_has_atodzuke = False
324 if self.player.is_open_hand:
325 best_one = results[0]
326 best_ukeire = 0
327 for result in results:
328 has_atodzuke = False
329 ukeire = 0
330 for wait_34 in result.waiting:
331 if wait_34 in not_suitable_tiles:
332 has_atodzuke = True
333 else:
334 ukeire += result.wait_to_ukeire[wait_34]
336 # let's consider atodzuke waits to be worse than non-atodzuke ones
337 if has_atodzuke:
338 ukeire /= 2
340 # FIXME consider sorting by cost_x_ukeire as well
341 if (ukeire > best_ukeire) or (ukeire >= best_ukeire and not has_atodzuke):
342 best_ukeire = ukeire
343 best_one = result
344 result_has_atodzuke = has_atodzuke
345 else:
346 if shanten == 0:
347 # FIXME save cost_x_ukeire to not calculate it twice
348 best_one = sorted(
349 results,
350 key=lambda x: (-x.ukeire, -self._estimate_cost_x_ukeire(x, call_riichi=call_riichi)[0]),
351 )[0]
352 else:
353 best_one = sorted(results, key=lambda x: -x.ukeire)[0]
354 best_ukeire = best_one.ukeire
356 sum_tiles += best_ukeire * live_tiles
358 # if we are going to have a tempai (on our second level) - let's also count its cost
359 if shanten == 0:
360 # temporary update players hand and discards for calculations
361 next_tile_in_hand = best_one.tile_to_discard_136
362 tile_for_discard = Tile(next_tile_in_hand, False)
363 self.player.tiles.remove(next_tile_in_hand)
364 self.player.discards.append(tile_for_discard)
366 cost_x_ukeire, _ = self._estimate_cost_x_ukeire(best_one, call_riichi=call_riichi)
367 if best_ukeire != 0:
368 average_costs.append(cost_x_ukeire / best_ukeire)
369 # we reduce tile valuation for atodzuke
370 if result_has_atodzuke:
371 cost_x_ukeire /= 2
372 sum_cost += cost_x_ukeire
374 # restore original players hand and discard state
375 self.player.tiles.append(next_tile_in_hand)
376 self.player.discards.remove(tile_for_discard)
378 self.player.tiles.remove(wait_136)
380 discard_option.ukeire_second = sum_tiles
381 if discard_option.shanten == 1:
382 if discard_option.ukeire != 0:
383 discard_option.average_second_level_waits = round(sum_tiles / discard_option.ukeire, 2)
385 discard_option.second_level_cost = sum_cost
386 if not average_costs:
387 discard_option.average_second_level_cost = 0
388 else:
389 discard_option.average_second_level_cost = int(sum(average_costs) / len(average_costs))
391 # restore original state of player hand and discards
392 self.restore_after_emulate_discard(player_tiles_original, player_discards_original)
394 def _decide_if_use_chiitoitsu(self, shanten_with_chiitoitsu, shanten_without_chiitoitsu):
395 # if it's late get 1-shanten for chiitoitsu instead of 2-shanten for another hand
396 if len(self.player.discards) <= 10:
397 border_shanten_without_chiitoitsu = 3
398 else:
399 border_shanten_without_chiitoitsu = 2
401 if (shanten_with_chiitoitsu == 0 and shanten_without_chiitoitsu >= 1) or (
402 shanten_with_chiitoitsu == 1 and shanten_without_chiitoitsu >= border_shanten_without_chiitoitsu
403 ):
404 shanten = shanten_with_chiitoitsu
405 use_chiitoitsu = True
406 else:
407 shanten = shanten_without_chiitoitsu
408 use_chiitoitsu = False
410 return shanten, use_chiitoitsu
412 @staticmethod
413 def _default_sorting_rule(x):
414 return (
415 x.shanten,
416 -x.ukeire,
417 x.valuation,
418 )
420 @staticmethod
421 def _sorting_rule_for_1_shanten(x):
422 return (-x.second_level_cost,) + HandBuilder._sorting_rule_for_1_2_3_shanten_simple(x)
424 @staticmethod
425 def _sorting_rule_for_1_2_3_shanten_simple(x):
426 return (
427 -x.ukeire_second,
428 -x.ukeire,
429 x.valuation,
430 )
432 @staticmethod
433 def _sorting_rule_for_2_3_shanten_with_isolated(x, closed_hand_34):
434 return (
435 -x.ukeire_second,
436 -x.ukeire,
437 -is_tile_strictly_isolated(closed_hand_34, x.tile_to_discard_34),
438 x.valuation,
439 )
441 @staticmethod
442 def _sorting_rule_for_4_or_more_shanten(x):
443 return (
444 -x.ukeire,
445 x.valuation,
446 )
448 @staticmethod
449 def _sorting_rule_for_betaori(x):
450 return (x.danger.get_weighted_danger(),) + HandBuilder._default_sorting_rule(x)
452 def _choose_safest_tile_or_skip_meld(self, discard_options, after_meld):
453 if after_meld:
454 return None
456 # we can't discard effective tile from the hand, let's fold
457 self.player.logger.debug(log.DISCARD_SAFE_TILE, "There are only dangerous tiles. Discard safest tile.")
458 return sorted(discard_options, key=self._sorting_rule_for_betaori)[0]
460 def _choose_safest_tile(self, discard_options):
461 return self._choose_safest_tile_or_skip_meld(discard_options, after_meld=False)
463 def _choose_best_tile(self, discard_options):
464 self.player.logger.debug(log.DISCARD_OPTIONS, "All discard candidates", discard_options)
466 return discard_options[0]
468 def _choose_best_tile_considering_threats(self, discard_options, sorting_rule):
469 assert discard_options
471 threats_present = [x for x in discard_options if x.danger.get_max_danger() != 0]
472 if threats_present:
473 # try to discard safest tile for calculated ukeire border
474 candidate = sorted(discard_options, key=sorting_rule)[0]
475 ukeire_border = max(
476 [
477 round((candidate.ukeire / 100) * DiscardOption.UKEIRE_DANGER_FILTER_PERCENTAGE),
478 DiscardOption.MIN_UKEIRE_DANGER_BORDER,
479 ]
480 )
482 discard_options_within_borders = sorted(
483 [x for x in discard_options if x.ukeire >= x.ukeire - ukeire_border],
484 key=lambda x: (x.danger.get_weighted_danger(),) + sorting_rule(x),
485 )
486 else:
487 discard_options_within_borders = discard_options
489 return self._choose_best_tile(discard_options_within_borders)
491 def _choose_first_option_or_safe_tiles(self, chosen_candidates, all_discard_options, after_meld, sorting_lambda):
492 # it looks like everything is fine
493 if len(chosen_candidates):
494 return self._choose_best_tile_considering_threats(chosen_candidates, sorting_lambda)
496 return self._choose_safest_tile_or_skip_meld(all_discard_options, after_meld)
498 def _choose_best_tanki_wait(self, discard_desc):
499 discard_desc = sorted(discard_desc, key=lambda k: (k["hand_cost"], -k["weighted_danger"]), reverse=True)
500 discard_desc = [x for x in discard_desc if x["hand_cost"] != 0]
502 # we are guaranteed to have at least one wait with cost by caller logic
503 assert len(discard_desc) > 0
505 if len(discard_desc) == 1:
506 return discard_desc[0]["discard_option"]
508 non_furiten_waits = [x for x in discard_desc if not x["is_furiten"]]
509 num_non_furiten_waits = len(non_furiten_waits)
510 if num_non_furiten_waits == 1:
511 return non_furiten_waits[0]["discard_option"]
512 elif num_non_furiten_waits > 1:
513 discard_desc = non_furiten_waits
515 best_discard_desc = [x for x in discard_desc if x["hand_cost"] == discard_desc[0]["hand_cost"]]
517 # first of all we choose the most expensive wait
518 if len(best_discard_desc) == 1:
519 return best_discard_desc[0]["discard_option"]
521 best_ukeire = best_discard_desc[0]["discard_option"].ukeire
522 diff = best_ukeire - best_discard_desc[1]["discard_option"].ukeire
523 # if both tanki waits have the same ukeire
524 if diff == 0:
525 # case when we have 2 or 3 tiles to wait for
526 if best_ukeire == 2 or best_ukeire == 3:
527 best_discard_desc = sorted(
528 best_discard_desc,
529 key=lambda k: (TankiWait.tanki_wait_same_ukeire_2_3_prio[k["tanki_type"]], -k["weighted_danger"]),
530 reverse=True,
531 )
532 return best_discard_desc[0]["discard_option"]
534 # case when we have 1 tile to wait for
535 if best_ukeire == 1:
536 best_discard_desc = sorted(
537 best_discard_desc,
538 key=lambda k: (TankiWait.tanki_wait_same_ukeire_1_prio[k["tanki_type"]], -k["weighted_danger"]),
539 reverse=True,
540 )
541 return best_discard_desc[0]["discard_option"]
543 # should never reach here
544 raise AssertionError("Can't chose tanki wait")
546 # if one tanki wait has 1 more tile to wait than the other we only choose the latter one if it is
547 # a wind or alike and the first one is not
548 if diff == 1:
549 prio_0 = TankiWait.tanki_wait_diff_ukeire_prio[best_discard_desc[0]["tanki_type"]]
550 prio_1 = TankiWait.tanki_wait_diff_ukeire_prio[best_discard_desc[1]["tanki_type"]]
551 if prio_1 > prio_0:
552 return best_discard_desc[1]["discard_option"]
554 return best_discard_desc[0]["discard_option"]
556 if diff > 1:
557 return best_discard_desc[0]["discard_option"]
559 # if everything is the same we just choose the first one
560 return best_discard_desc[0]["discard_option"]
562 def _is_waiting_furiten(self, tile_34):
563 discarded_tiles = [x.value // 4 for x in self.player.discards]
564 return tile_34 in discarded_tiles
566 def _is_discard_option_furiten(self, discard_option):
567 is_furiten = False
569 for waiting in discard_option.waiting:
570 is_furiten = is_furiten or self._is_waiting_furiten(waiting)
572 return is_furiten
574 def _choose_best_discard_in_tempai(self, discard_options, after_meld):
575 discard_desc = []
577 closed_tiles_34 = TilesConverter.to_34_array(self.player.closed_hand)
579 for discard_option in discard_options:
580 call_riichi = discard_option.with_riichi
581 tiles_original, discard_original = self.emulate_discard(discard_option)
583 is_furiten = self._is_discard_option_furiten(discard_option)
585 if len(discard_option.waiting) == 1:
586 waiting = discard_option.waiting[0]
588 cost_x_ukeire, hand_cost = self._estimate_cost_x_ukeire(discard_option, call_riichi)
590 # let's check if this is a tanki wait
591 results, tiles_34 = self.divide_hand(self.player.tiles, waiting)
592 result = results[0]
594 tanki_type = None
596 is_tanki = False
597 for hand_set in result:
598 if waiting not in hand_set:
599 continue
601 if is_pair(hand_set):
602 is_tanki = True
604 if is_honor(waiting):
605 # TODO: differentiate between self honor and honor for all players
606 if waiting in self.player.valued_honors:
607 tanki_type = TankiWait.TANKI_WAIT_ALL_YAKUHAI
608 else:
609 tanki_type = TankiWait.TANKI_WAIT_NON_YAKUHAI
610 break
612 simplified_waiting = simplify(waiting)
613 have_suji, have_kabe = self.check_suji_and_kabe(closed_tiles_34, waiting)
615 # TODO: not sure about suji/kabe priority, so we keep them same for now
616 if 3 <= simplified_waiting <= 5:
617 if have_suji or have_kabe:
618 tanki_type = TankiWait.TANKI_WAIT_456_KABE
619 else:
620 tanki_type = TankiWait.TANKI_WAIT_456_RAW
621 elif 2 <= simplified_waiting <= 6:
622 if have_suji or have_kabe:
623 tanki_type = TankiWait.TANKI_WAIT_37_KABE
624 else:
625 tanki_type = TankiWait.TANKI_WAIT_37_RAW
626 elif 1 <= simplified_waiting <= 7:
627 if have_suji or have_kabe:
628 tanki_type = TankiWait.TANKI_WAIT_28_KABE
629 else:
630 tanki_type = TankiWait.TANKI_WAIT_28_RAW
631 else:
632 if have_suji or have_kabe:
633 tanki_type = TankiWait.TANKI_WAIT_69_KABE
634 else:
635 tanki_type = TankiWait.TANKI_WAIT_69_RAW
636 break
638 tempai_descriptor = {
639 "discard_option": discard_option,
640 "hand_cost": hand_cost,
641 "cost_x_ukeire": cost_x_ukeire,
642 "is_furiten": is_furiten,
643 "is_tanki": is_tanki,
644 "tanki_type": tanki_type,
645 "max_danger": discard_option.danger.get_max_danger(),
646 "sum_danger": discard_option.danger.get_sum_danger(),
647 "weighted_danger": discard_option.danger.get_weighted_danger(),
648 }
649 discard_desc.append(tempai_descriptor)
650 else:
651 cost_x_ukeire, _ = self._estimate_cost_x_ukeire(discard_option, call_riichi)
653 tempai_descriptor = {
654 "discard_option": discard_option,
655 "hand_cost": None,
656 "cost_x_ukeire": cost_x_ukeire,
657 "is_furiten": is_furiten,
658 "is_tanki": False,
659 "tanki_type": None,
660 "max_danger": discard_option.danger.get_max_danger(),
661 "sum_danger": discard_option.danger.get_sum_danger(),
662 "weighted_danger": discard_option.danger.get_weighted_danger(),
663 }
664 discard_desc.append(tempai_descriptor)
666 # save descriptor to discard option for future users
667 discard_option.tempai_descriptor = tempai_descriptor
669 # reverse all temporary tile tweaks
670 self.restore_after_emulate_discard(tiles_original, discard_original)
672 discard_desc = sorted(discard_desc, key=lambda k: (-k["cost_x_ukeire"], k["is_furiten"], k["weighted_danger"]))
674 # if we don't have any good options, e.g. all our possible waits are karaten
675 if discard_desc[0]["cost_x_ukeire"] == 0:
676 # we still choose between options that give us tempai, because we may be going to formal tempai
677 # with no hand cost
678 return self._choose_safest_tile(discard_options)
680 num_tanki_waits = len([x for x in discard_desc if x["is_tanki"]])
682 # what if all our waits are tanki waits? we need a special handling for that case
683 if num_tanki_waits == len(discard_options):
684 return self._choose_best_tanki_wait(discard_desc)
686 best_discard_desc = [x for x in discard_desc if x["cost_x_ukeire"] == discard_desc[0]["cost_x_ukeire"]]
687 best_discard_desc = sorted(best_discard_desc, key=lambda k: (k["is_furiten"], k["weighted_danger"]))
689 # if we have several options that give us similar wait
690 return best_discard_desc[0]["discard_option"]
692 # this method is used when there are no threats but we are deciding if we should keep safe tile or useful tile
693 def _simplified_danger_valuation(self, discard_option):
694 tile_34 = discard_option.tile_to_discard_34
695 tile_136 = discard_option.tile_to_discard_136
696 number_of_revealed_tiles = self.player.number_of_revealed_tiles(
697 tile_34, TilesConverter.to_34_array(self.player.closed_hand)
698 )
699 if is_honor(tile_34):
700 if not self.player.table.is_common_yakuhai(tile_34):
701 if number_of_revealed_tiles == 4:
702 simple_danger = 0
703 elif number_of_revealed_tiles == 3:
704 simple_danger = 10
705 elif number_of_revealed_tiles == 2:
706 simple_danger = 20
707 else:
708 simple_danger = 30
709 else:
710 if number_of_revealed_tiles == 4:
711 simple_danger = 0
712 elif number_of_revealed_tiles == 3:
713 simple_danger = 11
714 elif number_of_revealed_tiles == 2:
715 simple_danger = 21
716 else:
717 simple_danger = 32
718 elif is_terminal(tile_34):
719 simple_danger = 100
720 elif simplify(tile_34) < 2 or simplify(tile_34) > 6:
721 # 2, 3 or 7, 8
722 simple_danger = 200
723 else:
724 # 4, 5, 6
725 simple_danger = 300
727 if simple_danger != 0:
728 simple_danger += plus_dora(
729 tile_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora
730 )
732 return simple_danger
734 def _sort_1_shanten_discard_options_no_threats(self, discard_options, best_ukeire):
735 if self.player.round_step < 5 or best_ukeire <= 12:
736 return self._choose_best_tile(sorted(discard_options, key=self._sorting_rule_for_1_shanten))
737 elif self.player.round_step < 13:
738 # discard more dangerous tiles beforehand
739 self.player.logger.debug(
740 log.DISCARD_OPTIONS,
741 "There are no threats yet, better discard useless dangerous tile beforehand",
742 discard_options,
743 )
744 danger_sign = -1
745 else:
746 # late round - discard safer tiles first
747 self.player.logger.debug(
748 log.DISCARD_OPTIONS,
749 "There are no visible threats, but it's late, better keep useless dangerous tiles",
750 discard_options,
751 )
752 danger_sign = 1
754 return self._choose_best_tile(
755 sorted(
756 discard_options,
757 key=lambda x: (
758 -x.second_level_cost,
759 -x.ukeire_second,
760 -x.ukeire,
761 danger_sign * self._simplified_danger_valuation(x),
762 ),
763 ),
764 )
766 def _choose_best_discard_with_1_shanten(
767 self, discard_options, after_meld, one_shanten_ukeire2_calculated_beforehand
768 ):
769 discard_options = sorted(discard_options, key=lambda x: (x.shanten, -x.ukeire))
770 first_option = discard_options[0]
772 # first we filter by ukeire
773 ukeire_borders = self._choose_ukeire_borders(
774 first_option, DiscardOption.UKEIRE_FIRST_FILTER_PERCENTAGE, "ukeire"
775 )
776 possible_options = self._filter_list_by_ukeire_borders(discard_options, first_option.ukeire, ukeire_borders)
778 # FIXME: hack, sometimes we have already calculated it
779 if not one_shanten_ukeire2_calculated_beforehand:
780 for x in possible_options:
781 self.calculate_second_level_ukeire(x, after_meld)
783 # then we filter by ukeire2
784 possible_options = sorted(possible_options, key=self._sorting_rule_for_1_2_3_shanten_simple)
785 possible_options = self._filter_list_by_percentage(
786 possible_options, "ukeire_second", DiscardOption.UKEIRE_SECOND_FILTER_PERCENTAGE
787 )
789 threats_present = [x for x in discard_options if x.danger.get_max_danger() != 0]
790 if threats_present:
791 return self._choose_best_tile_considering_threats(
792 sorted(possible_options, key=self._sorting_rule_for_1_shanten),
793 self._sorting_rule_for_1_shanten,
794 )
795 else:
796 # if there are no theats we try to either keep or discard potentially dangerous tiles depending on the round
797 return self._sort_1_shanten_discard_options_no_threats(possible_options, first_option.ukeire)
799 def _choose_best_discard_with_2_3_shanten(self, discard_options, after_meld):
800 discard_options = sorted(discard_options, key=lambda x: (x.shanten, -x.ukeire))
801 first_option = discard_options[0]
802 closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand)
804 # first we filter by ukeire
805 ukeire_borders = self._choose_ukeire_borders(
806 first_option, DiscardOption.UKEIRE_FIRST_FILTER_PERCENTAGE, "ukeire"
807 )
808 possible_options = self._filter_list_by_ukeire_borders(discard_options, first_option.ukeire, ukeire_borders)
810 for x in possible_options:
811 self.calculate_second_level_ukeire(x, after_meld)
813 # then we filter by ukeire 2
814 possible_options = sorted(
815 possible_options, key=lambda x: self._sorting_rule_for_2_3_shanten_with_isolated(x, closed_hand_34)
816 )
817 possible_options = self._filter_list_by_percentage(
818 possible_options, "ukeire_second", DiscardOption.UKEIRE_SECOND_FILTER_PERCENTAGE
819 )
821 possible_options = self._try_keep_doras(
822 possible_options, "ukeire_second", DiscardOption.UKEIRE_SECOND_FILTER_PERCENTAGE
823 )
824 assert possible_options
826 self.player.logger.debug(log.DISCARD_OPTIONS, "Candidates after filtering by ukeire2", context=possible_options)
828 return self._choose_best_tile_considering_threats(
829 sorted(possible_options, key=lambda x: self._sorting_rule_for_2_3_shanten_with_isolated(x, closed_hand_34)),
830 self._sorting_rule_for_1_2_3_shanten_simple,
831 )
833 def _choose_best_discard_with_4_or_more_shanten(self, discard_options):
834 discard_options = sorted(discard_options, key=lambda x: (x.shanten, -x.ukeire))
835 first_option = discard_options[0]
837 # we filter by ukeire
838 ukeire_borders = self._choose_ukeire_borders(
839 first_option, DiscardOption.UKEIRE_FIRST_FILTER_PERCENTAGE, "ukeire"
840 )
841 possible_options = self._filter_list_by_ukeire_borders(discard_options, first_option.ukeire, ukeire_borders)
843 possible_options = sorted(possible_options, key=self._sorting_rule_for_4_or_more_shanten)
845 possible_options = self._try_keep_doras(
846 possible_options, "ukeire", DiscardOption.UKEIRE_FIRST_FILTER_PERCENTAGE
847 )
848 assert possible_options
850 closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand)
851 isolated_tiles = [
852 x for x in possible_options if is_tile_strictly_isolated(closed_hand_34, x.tile_to_discard_34)
853 ]
854 # isolated tiles should be discarded first
855 if isolated_tiles:
856 possible_options = isolated_tiles
858 # let's sort tiles by value and let's choose less valuable tile to discard
859 return self._choose_best_tile_considering_threats(
860 sorted(possible_options, key=self._sorting_rule_for_4_or_more_shanten),
861 self._sorting_rule_for_4_or_more_shanten,
862 )
864 def _try_keep_doras(self, discard_options, ukeire_field, filter_percentage):
865 tiles_without_dora = [x for x in discard_options if x.count_of_dora == 0]
866 # we have only dora candidates to discard
867 if not tiles_without_dora:
868 min_dora = min([x.count_of_dora for x in discard_options])
869 min_dora_list = [x for x in discard_options if x.count_of_dora == min_dora]
870 possible_options = min_dora_list
871 else:
872 # filter again - this time only tiles without dora
873 possible_options = self._filter_list_by_percentage(tiles_without_dora, ukeire_field, filter_percentage)
875 return possible_options
877 def _find_acceptable_path_to_tempai(self, acceptable_discard_options, shanten):
878 # this might be a bit conservative, because in tempai danger borders will be higher, but since
879 # we expect that we need to discard a few more dangerous tiles before we get tempai, if we push
880 # we can use danger borders for our current shanten number and it all compensates
881 acceptable_discard_options_with_same_shanten = [x for x in acceptable_discard_options if x.shanten == shanten]
882 # +1 because we need to discard one more tile to get to lower shanten number one more time
883 # so if we want to meld and get 1-shanten we should have at least two tiles in hand we can discard
884 # that don't increase shanten number
885 if len(acceptable_discard_options_with_same_shanten) < shanten + 1:
886 # there is no acceptable way for tempai even in theory
887 return False
889 @staticmethod
890 def _filter_list_by_ukeire_borders(discard_options, ukeire, ukeire_borders):
891 filteted = []
893 for discard_option in discard_options:
894 # let's choose tiles that are close to the max ukeire tile
895 if discard_option.ukeire >= ukeire - ukeire_borders:
896 filteted.append(discard_option)
898 return filteted
900 @staticmethod
901 def _filter_list_by_percentage(items, attribute, percentage):
902 filtered_options = []
903 first_option = items[0]
904 ukeire_borders = round((getattr(first_option, attribute) / 100) * percentage)
905 for x in items:
906 if getattr(x, attribute) >= getattr(first_option, attribute) - ukeire_borders:
907 filtered_options.append(x)
908 return filtered_options
910 @staticmethod
911 def _choose_ukeire_borders(first_option, border_percentage, border_field):
912 ukeire_borders = round((getattr(first_option, border_field) / 100) * border_percentage)
914 if first_option.shanten == 0 and ukeire_borders < DiscardOption.MIN_UKEIRE_TEMPAI_BORDER:
915 ukeire_borders = DiscardOption.MIN_UKEIRE_TEMPAI_BORDER
917 if first_option.shanten == 1 and ukeire_borders < DiscardOption.MIN_UKEIRE_SHANTEN_1_BORDER:
918 ukeire_borders = DiscardOption.MIN_UKEIRE_SHANTEN_1_BORDER
920 if first_option.shanten >= 2 and ukeire_borders < DiscardOption.MIN_UKEIRE_SHANTEN_2_BORDER:
921 ukeire_borders = DiscardOption.MIN_UKEIRE_SHANTEN_2_BORDER
923 return ukeire_borders
925 def _estimate_cost_x_ukeire(self, discard_option, call_riichi):
926 cost_x_ukeire_tsumo = 0
927 cost_x_ukeire_ron = 0
928 hand_cost_tsumo = 0
929 hand_cost_ron = 0
931 is_furiten = self._is_discard_option_furiten(discard_option)
933 for waiting in discard_option.waiting:
934 hand_value = self.player.ai.estimate_hand_value_or_get_from_cache(
935 waiting, call_riichi=call_riichi, is_tsumo=True
936 )
937 if hand_value.error is None:
938 hand_cost_tsumo = hand_value.cost["main"] + 2 * hand_value.cost["additional"]
939 cost_x_ukeire_tsumo += hand_cost_tsumo * discard_option.wait_to_ukeire[waiting]
941 if not is_furiten:
942 hand_value = self.player.ai.estimate_hand_value_or_get_from_cache(
943 waiting, call_riichi=call_riichi, is_tsumo=False
944 )
945 if hand_value.error is None:
946 hand_cost_ron = hand_value.cost["main"]
947 cost_x_ukeire_ron += hand_cost_ron * discard_option.wait_to_ukeire[waiting]
949 # these are abstract numbers used to compare different waits
950 # some don't have yaku, some furiten, etc.
951 # so we use an abstract formula of 1 tsumo cost + 3 ron costs
952 cost_x_ukeire = (cost_x_ukeire_tsumo + 3 * cost_x_ukeire_ron) // 4
954 if len(discard_option.waiting) == 1:
955 hand_cost = (hand_cost_tsumo + 3 * hand_cost_ron) // 4
956 else:
957 hand_cost = None
959 return cost_x_ukeire, hand_cost
961 def _find_live_tile(self, tile_34):
962 for i in range(0, 4):
963 tile = tile_34 * 4 + i
964 if not (tile in self.player.closed_hand) and not (tile in self.player.meld_tiles):
965 return tile
967 return None
969 def _assert_hand_correctness(self):
970 # we must always have correct hand to discard from, e.g. we cannot discard when we have 13 tiles
971 num_kans = len([x for x in self.player.melds if x.type == MeldPrint.KAN or x.type == MeldPrint.SHOUMINKAN])
972 total_tiles = len(self.player.tiles)
973 allowed_tiles = 14 + num_kans
974 assert total_tiles == allowed_tiles, f"{total_tiles} != {allowed_tiles}"
975 assert (
976 len(self.player.closed_hand) == 2
977 or len(self.player.closed_hand) == 5
978 or len(self.player.closed_hand) == 8
979 or len(self.player.closed_hand) == 11
980 or len(self.player.closed_hand) == 14
981 )
984class TankiWait:
985 TANKI_WAIT_NON_YAKUHAI = 1
986 TANKI_WAIT_SELF_YAKUHAI = 2
987 TANKI_WAIT_ALL_YAKUHAI = 3
988 TANKI_WAIT_69_KABE = 4
989 TANKI_WAIT_69_SUJI = 5
990 TANKI_WAIT_69_RAW = 6
991 TANKI_WAIT_28_KABE = 7
992 TANKI_WAIT_28_SUJI = 8
993 TANKI_WAIT_28_RAW = 9
994 TANKI_WAIT_37_KABE = 10
995 TANKI_WAIT_37_SUJI = 11
996 TANKI_WAIT_37_RAW = 12
997 TANKI_WAIT_456_KABE = 13
998 TANKI_WAIT_456_SUJI = 14
999 TANKI_WAIT_456_RAW = 15
1001 tanki_wait_same_ukeire_2_3_prio = {
1002 TANKI_WAIT_NON_YAKUHAI: 15,
1003 TANKI_WAIT_69_KABE: 14,
1004 TANKI_WAIT_69_SUJI: 14,
1005 TANKI_WAIT_SELF_YAKUHAI: 13,
1006 TANKI_WAIT_ALL_YAKUHAI: 12,
1007 TANKI_WAIT_28_KABE: 11,
1008 TANKI_WAIT_28_SUJI: 11,
1009 TANKI_WAIT_37_KABE: 10,
1010 TANKI_WAIT_37_SUJI: 10,
1011 TANKI_WAIT_69_RAW: 9,
1012 TANKI_WAIT_456_KABE: 8,
1013 TANKI_WAIT_456_SUJI: 8,
1014 TANKI_WAIT_28_RAW: 7,
1015 TANKI_WAIT_456_RAW: 6,
1016 TANKI_WAIT_37_RAW: 5,
1017 }
1019 tanki_wait_same_ukeire_1_prio = {
1020 TANKI_WAIT_NON_YAKUHAI: 15,
1021 TANKI_WAIT_SELF_YAKUHAI: 14,
1022 TANKI_WAIT_ALL_YAKUHAI: 13,
1023 TANKI_WAIT_69_KABE: 12,
1024 TANKI_WAIT_69_SUJI: 12,
1025 TANKI_WAIT_28_KABE: 11,
1026 TANKI_WAIT_28_SUJI: 11,
1027 TANKI_WAIT_37_KABE: 10,
1028 TANKI_WAIT_37_SUJI: 10,
1029 TANKI_WAIT_69_RAW: 9,
1030 TANKI_WAIT_456_KABE: 8,
1031 TANKI_WAIT_456_SUJI: 8,
1032 TANKI_WAIT_28_RAW: 7,
1033 TANKI_WAIT_456_RAW: 6,
1034 TANKI_WAIT_37_RAW: 5,
1035 }
1037 tanki_wait_diff_ukeire_prio = {
1038 TANKI_WAIT_NON_YAKUHAI: 1,
1039 TANKI_WAIT_SELF_YAKUHAI: 1,
1040 TANKI_WAIT_ALL_YAKUHAI: 1,
1041 TANKI_WAIT_69_KABE: 1,
1042 TANKI_WAIT_69_SUJI: 1,
1043 TANKI_WAIT_28_KABE: 0,
1044 TANKI_WAIT_28_SUJI: 0,
1045 TANKI_WAIT_37_KABE: 0,
1046 TANKI_WAIT_37_SUJI: 0,
1047 TANKI_WAIT_69_RAW: 0,
1048 TANKI_WAIT_456_KABE: 0,
1049 TANKI_WAIT_456_SUJI: 0,
1050 TANKI_WAIT_28_RAW: 0,
1051 TANKI_WAIT_456_RAW: 0,
1052 TANKI_WAIT_37_RAW: 0,
1053 }