Coverage for project/game/ai/strategies_v2/main.py : 77%

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
1import utils.decisions_constants as log
2from mahjong.tile import TilesConverter
3from mahjong.utils import is_chi, is_honor, is_man, is_pin, is_pon, is_sou, is_terminal, plus_dora, simplify
4from utils.decisions_logger import MeldPrint
7class BaseStrategy:
8 YAKUHAI = 0
9 HONITSU = 1
10 TANYAO = 2
11 FORMAL_TEMPAI = 3
12 CHINITSU = 4
13 COMMON_OPEN_TEMPAI = 6
15 TYPES = {
16 YAKUHAI: "Yakuhai",
17 HONITSU: "Honitsu",
18 TANYAO: "Tanyao",
19 FORMAL_TEMPAI: "Formal Tempai",
20 CHINITSU: "Chinitsu",
21 COMMON_OPEN_TEMPAI: "Common Open Tempai",
22 }
24 not_suitable_tiles = []
25 player = None
26 type = None
27 # number of shanten where we can start to open hand
28 min_shanten = 7
29 go_for_atodzuke = False
31 dora_count_total = 0
32 dora_count_central = 0
33 dora_count_not_central = 0
34 aka_dora_count = 0
35 dora_count_honor = 0
37 def __init__(self, strategy_type, player):
38 self.type = strategy_type
39 self.player = player
40 self.go_for_atodzuke = False
42 def __str__(self):
43 return self.TYPES[self.type]
45 def get_open_hand_han(self):
46 return 0
48 def should_activate_strategy(self, tiles_136, meld_tile=None) -> bool:
49 """
50 Based on player hand and table situation
51 we can determine should we use this strategy or not.
52 """
53 self.calculate_dora_count(tiles_136)
54 return True
56 def can_meld_into_agari(self) -> bool:
57 """
58 Is melding into agari allowed with this strategy.
59 By default, the logic is the following: if we have any
60 non-suitable tiles, we can meld into agari state,
61 because we'll throw them away after meld.
62 Otherwise, there is no point.
63 """
64 for tile in self.player.tiles:
65 if not self.is_tile_suitable(tile):
66 return True
67 return False
69 def is_tile_suitable(self, tile):
70 """
71 Can tile be used for open hand strategy or not
72 :param tile: in 136 tiles format
73 :return: boolean
74 """
75 raise NotImplementedError()
77 def determine_what_to_discard(self, discard_options, hand, open_melds):
78 first_option = sorted(discard_options, key=lambda x: x.shanten)[0]
79 shanten = first_option.shanten
81 # for riichi we don't need to discard useful tiles
82 if shanten == 0 and not self.player.is_open_hand:
83 return discard_options
85 # mark all not suitable tiles as ready to discard
86 # even if they not should be discarded by uke-ire
87 for x in discard_options:
88 if not self.is_tile_suitable(x.tile_to_discard_136):
89 x.had_to_be_discarded = True
91 return discard_options
93 def try_to_call_meld(self, tile, is_kamicha_discard, new_tiles):
94 """
95 Determine should we call a meld or not.
96 If yes, it will return MeldPrint object and tile to discard
97 :param tile: 136 format tile
98 :param is_kamicha_discard: boolean
99 :param new_tiles:
100 :return: MeldPrint and DiscardOption objects
101 """
102 if self.player.in_riichi:
103 return None, None
105 closed_hand = self.player.closed_hand[:]
107 # we can't open hand anymore
108 if len(closed_hand) == 1:
109 return None, None
111 # we can't use this tile for our chosen strategy
112 if not self.is_tile_suitable(tile):
113 return None, None
115 discarded_tile = tile // 4
116 closed_hand_34 = TilesConverter.to_34_array(closed_hand + [tile])
118 combinations = []
119 first_index = 0
120 second_index = 0
121 if is_man(discarded_tile):
122 first_index = 0
123 second_index = 8
124 elif is_pin(discarded_tile):
125 first_index = 9
126 second_index = 17
127 elif is_sou(discarded_tile):
128 first_index = 18
129 second_index = 26
131 if second_index == 0:
132 # honor tiles
133 if closed_hand_34[discarded_tile] == 3:
134 combinations = [[[discarded_tile] * 3]]
135 else:
136 # to avoid not necessary calculations
137 # we can check only tiles around +-2 discarded tile
138 first_limit = discarded_tile - 2
139 if first_limit < first_index:
140 first_limit = first_index
142 second_limit = discarded_tile + 2
143 if second_limit > second_index:
144 second_limit = second_index
146 combinations = self.player.ai.hand_divider.find_valid_combinations(
147 closed_hand_34, first_limit, second_limit, True
148 )
150 if combinations:
151 combinations = combinations[0]
153 possible_melds = []
154 for best_meld_34 in combinations:
155 # we can call pon from everyone
156 if is_pon(best_meld_34) and discarded_tile in best_meld_34:
157 if best_meld_34 not in possible_melds:
158 possible_melds.append(best_meld_34)
160 # we can call chi only from left player
161 if is_chi(best_meld_34) and is_kamicha_discard and discarded_tile in best_meld_34:
162 if best_meld_34 not in possible_melds:
163 possible_melds.append(best_meld_34)
165 # we can call melds only with allowed tiles
166 validated_melds = []
167 for meld in possible_melds:
168 if (
169 self.is_tile_suitable(meld[0] * 4)
170 and self.is_tile_suitable(meld[1] * 4)
171 and self.is_tile_suitable(meld[2] * 4)
172 ):
173 validated_melds.append(meld)
174 possible_melds = validated_melds
176 if not possible_melds:
177 return None, None
179 chosen_meld_dict = self._find_best_meld_to_open(tile, possible_melds, new_tiles, closed_hand, tile)
180 # we didn't find a good discard candidate after open meld
181 if not chosen_meld_dict:
182 return None, None
184 selected_tile = chosen_meld_dict["discard_tile"]
185 meld = chosen_meld_dict["meld"]
187 shanten = selected_tile.shanten
188 had_to_be_called = self.meld_had_to_be_called(tile)
189 had_to_be_called = had_to_be_called or selected_tile.had_to_be_discarded
191 # each strategy can use their own value to min shanten number
192 if shanten > self.min_shanten:
193 self.player.logger.debug(
194 log.MELD_DEBUG,
195 "After meld shanten is too high for our strategy. Abort melding.",
196 )
197 return None, None
199 # sometimes we had to call tile, even if it will not improve our hand
200 # otherwise we can call only with improvements of shanten
201 if not had_to_be_called and shanten >= self.player.ai.shanten:
202 self.player.logger.debug(
203 log.MELD_DEBUG,
204 "Meld is not improving hand shanten. Abort melding.",
205 )
206 return None, None
208 if not self.validate_meld(chosen_meld_dict):
209 self.player.logger.debug(
210 log.MELD_DEBUG,
211 "Meld is suitable for strategy logic. Abort melding.",
212 )
213 return None, None
215 if not self.should_push_against_threats(chosen_meld_dict):
216 self.player.logger.debug(
217 log.MELD_DEBUG,
218 "Meld is too dangerous to call. Abort melding.",
219 )
220 return None, None
222 return meld, selected_tile
224 def should_push_against_threats(self, chosen_meld_dict) -> bool:
225 selected_tile = chosen_meld_dict["discard_tile"]
227 if selected_tile.shanten <= 1:
228 return True
230 threats = self.player.ai.defence.get_threatening_players()
231 if not threats:
232 return True
234 # don't open garbage hand against threats
235 if selected_tile.shanten >= 3:
236 return False
238 tile_136 = selected_tile.tile_to_discard_136
239 if len(threats) == 1:
240 threat_hand_cost = threats[0].get_assumed_hand_cost(tile_136)
241 # expensive threat
242 # and our hand is not good
243 # let's not open this
244 if threat_hand_cost >= 7700:
245 return False
246 else:
247 min_threat_hand_cost = min([x.get_assumed_hand_cost(tile_136) for x in threats])
248 # 2+ threats
249 # and they are not cheap
250 # so, let's skip opening of bad hand
251 if min_threat_hand_cost >= 5200:
252 return False
254 return True
256 def validate_meld(self, chosen_meld_dict):
257 """
258 In some cased we want additionally check that meld is suitable to the strategy
259 """
260 if self.player.is_open_hand:
261 return True
263 if not self.player.ai.placement.is_oorasu:
264 return True
266 # don't care about not enough cost if we are the dealer
267 if self.player.is_dealer:
268 return True
270 placement = self.player.ai.placement.get_current_placement()
271 if not placement:
272 return True
274 needed_cost = self.player.ai.placement.get_minimal_cost_needed_considering_west(placement=placement)
275 if needed_cost <= 1000:
276 return True
278 selected_tile = chosen_meld_dict["discard_tile"]
279 if selected_tile.ukeire == 0:
280 self.player.logger.debug(
281 log.MELD_DEBUG, "We need to get out of 4th place, but this meld leaves us with zero ukeire"
282 )
283 return False
285 logger_context = {
286 "placement": placement,
287 "meld": chosen_meld_dict,
288 "needed_cost": needed_cost,
289 }
291 if selected_tile.shanten == 0:
292 if not selected_tile.tempai_descriptor:
293 return True
295 # tempai has special logger context
296 logger_context = {
297 "placement": placement,
298 "meld": chosen_meld_dict,
299 "needed_cost": needed_cost,
300 "tempai_descriptor": selected_tile.tempai_descriptor,
301 }
303 if selected_tile.tempai_descriptor["hand_cost"]:
304 hand_cost = selected_tile.tempai_descriptor["hand_cost"]
305 else:
306 hand_cost = selected_tile.tempai_descriptor["cost_x_ukeire"] / selected_tile.ukeire
308 # optimistic condition - direct ron
309 if hand_cost * 2 < needed_cost:
310 self.player.logger.debug(
311 log.MELD_DEBUG, "No chance to comeback from 4th with this meld, so keep hand closed", logger_context
312 )
313 return False
314 elif selected_tile.shanten == 1:
315 if selected_tile.average_second_level_cost is None:
316 return True
318 # optimistic condition - direct ron
319 if selected_tile.average_second_level_cost * 2 < needed_cost:
320 self.player.logger.debug(
321 log.MELD_DEBUG, "No chance to comeback from 4th with this meld, so keep hand closed", logger_context
322 )
323 return False
324 else:
325 simple_han_scale = [0, 1000, 2000, 3900, 7700, 8000, 12000, 12000]
326 num_han = self.get_open_hand_han() + self.dora_count_total
327 if num_han < len(simple_han_scale):
328 hand_cost = simple_han_scale[num_han]
329 # optimistic condition - direct ron
330 if hand_cost * 2 < needed_cost:
331 self.player.logger.debug(
332 log.MELD_DEBUG,
333 "No chance to comeback from 4th with this meld, so keep hand closed",
334 logger_context,
335 )
336 return False
338 self.player.logger.debug(log.MELD_DEBUG, "This meld should allow us to comeback from 4th", logger_context)
339 return True
341 def meld_had_to_be_called(self, tile):
342 """
343 For special cases meld had to be called even if shanten number will not be increased
344 :param tile: in 136 tiles format
345 :return: boolean
346 """
347 return False
349 def calculate_dora_count(self, tiles_136):
350 self.dora_count_central = 0
351 self.dora_count_not_central = 0
352 self.aka_dora_count = 0
354 for tile_136 in tiles_136:
355 tile_34 = tile_136 // 4
357 dora_count = plus_dora(
358 tile_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora
359 )
361 if not dora_count:
362 continue
364 if is_honor(tile_34):
365 self.dora_count_not_central += dora_count
366 self.dora_count_honor += dora_count
367 elif is_terminal(tile_34):
368 self.dora_count_not_central += dora_count
369 else:
370 self.dora_count_central += dora_count
372 self.dora_count_central += self.aka_dora_count
373 self.dora_count_total = self.dora_count_central + self.dora_count_not_central
375 def _find_best_meld_to_open(self, call_tile_136, possible_melds, new_tiles, closed_hand, discarded_tile):
376 all_tiles_are_suitable = True
377 for tile_136 in closed_hand:
378 all_tiles_are_suitable &= self.is_tile_suitable(tile_136)
380 final_results = []
381 for meld_34 in possible_melds:
382 # in order to fully emulate the possible hand with meld, we save original melds state,
383 # modify player's melds and then restore original melds state after everything is done
384 melds_original = self.player.melds[:]
385 tiles_original = self.player.tiles[:]
387 tiles = self._find_meld_tiles(closed_hand, meld_34, discarded_tile)
388 meld = MeldPrint()
389 meld.type = is_chi(meld_34) and MeldPrint.CHI or MeldPrint.PON
390 meld.tiles = sorted(tiles)
392 self.player.logger.debug(
393 log.MELD_HAND, f"Hand: {self._format_hand_for_print(closed_hand, discarded_tile, self.player.melds)}"
394 )
396 # update player hand state to emulate new situation and choose what to discard
397 self.player.tiles = new_tiles[:]
398 self.player.add_called_meld(meld)
400 selected_tile = self.player.ai.hand_builder.choose_tile_to_discard(after_meld=True)
401 closed_hand_tiles_after_meld = self.player.closed_hand[:]
403 # restore original tiles and melds state
404 self.player.tiles = tiles_original
405 self.player.melds = melds_original
407 # we can't find a good discard candidate, so let's skip this
408 if not selected_tile:
409 self.player.logger.debug(log.MELD_DEBUG, "Can't find discard candidate after meld. Abort melding.")
410 continue
412 if not all_tiles_are_suitable and self.is_tile_suitable(selected_tile.tile_to_discard_136):
413 self.player.logger.debug(
414 log.MELD_DEBUG,
415 "We have tiles in our hand that are not suitable to current strategy, "
416 "but we are going to discard tile that we need. Abort melding.",
417 )
418 continue
420 call_tile_34 = call_tile_136 // 4
421 # we can't discard the same tile that we called
422 if selected_tile.tile_to_discard_34 == call_tile_34:
423 self.player.logger.debug(
424 log.MELD_DEBUG, "We can't discard same tile that we used for meld. Abort melding."
425 )
426 continue
428 # we can't discard tile from the other end of the same ryanmen that we called
429 if not is_honor(selected_tile.tile_to_discard_34) and meld.type == MeldPrint.CHI:
430 if is_sou(selected_tile.tile_to_discard_34) and is_sou(call_tile_34):
431 same_suit = True
432 elif is_man(selected_tile.tile_to_discard_34) and is_man(call_tile_34):
433 same_suit = True
434 elif is_pin(selected_tile.tile_to_discard_34) and is_pin(call_tile_34):
435 same_suit = True
436 else:
437 same_suit = False
439 if same_suit:
440 simplified_meld_0 = simplify(meld.tiles[0] // 4)
441 simplified_meld_1 = simplify(meld.tiles[1] // 4)
442 simplified_call = simplify(call_tile_34)
443 simplified_discard = simplify(selected_tile.tile_to_discard_34)
444 kuikae = False
445 if simplified_discard == simplified_call - 3:
446 kuikae_set = [simplified_call - 1, simplified_call - 2]
447 if simplified_meld_0 in kuikae_set and simplified_meld_1 in kuikae_set:
448 kuikae = True
449 elif simplified_discard == simplified_call + 3:
450 kuikae_set = [simplified_call + 1, simplified_call + 2]
451 if simplified_meld_0 in kuikae_set and simplified_meld_1 in kuikae_set:
452 kuikae = True
454 if kuikae:
455 tile_str = TilesConverter.to_one_line_string(
456 [selected_tile.tile_to_discard_136], print_aka_dora=self.player.table.has_aka_dora
457 )
458 self.player.logger.debug(
459 log.MELD_DEBUG,
460 f"Kuikae discard {tile_str} candidate. Abort melding.",
461 )
462 continue
464 final_results.append(
465 {
466 "discard_tile": selected_tile,
467 "meld_print": TilesConverter.to_one_line_string([meld_34[0] * 4, meld_34[1] * 4, meld_34[2] * 4]),
468 "meld": meld,
469 "closed_hand_tiles_after_meld": closed_hand_tiles_after_meld,
470 }
471 )
473 if not final_results:
474 self.player.logger.debug(log.MELD_DEBUG, "There are no good discards after melding.")
475 return None
477 final_results = sorted(
478 final_results,
479 key=lambda x: (x["discard_tile"].shanten, -x["discard_tile"].ukeire, x["discard_tile"].valuation),
480 )
482 self.player.logger.debug(
483 log.MELD_PREPARE,
484 "Tiles could be used for open meld",
485 context=final_results,
486 )
487 return final_results[0]
489 @staticmethod
490 def _find_meld_tiles(closed_hand, meld_34, discarded_tile):
491 discarded_tile_34 = discarded_tile // 4
492 meld_34_copy = meld_34[:]
493 closed_hand_copy = closed_hand[:]
495 meld_34_copy.remove(discarded_tile_34)
497 first_tile = TilesConverter.find_34_tile_in_136_array(meld_34_copy[0], closed_hand_copy)
498 closed_hand_copy.remove(first_tile)
500 second_tile = TilesConverter.find_34_tile_in_136_array(meld_34_copy[1], closed_hand_copy)
501 closed_hand_copy.remove(second_tile)
503 tiles = [first_tile, second_tile, discarded_tile]
505 return tiles
507 def _format_hand_for_print(self, tiles, new_tile, melds):
508 tiles_string = TilesConverter.to_one_line_string(tiles, print_aka_dora=self.player.table.has_aka_dora)
509 tile_string = TilesConverter.to_one_line_string([new_tile], print_aka_dora=self.player.table.has_aka_dora)
510 hand_string = f"{tiles_string} + {tile_string}"
511 hand_string += " [{}]".format(
512 ", ".join(
513 [
514 TilesConverter.to_one_line_string(x.tiles, print_aka_dora=self.player.table.has_aka_dora)
515 for x in melds
516 ]
517 )
518 )
519 return hand_string