Coverage for project/game/ai/strategies/main.py : 92%

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):
49 """
50 Based on player hand and table situation
51 we can determine should we use this strategy or not.
52 :param: tiles_136
53 :return: boolean
54 """
55 self.calculate_dora_count(tiles_136)
57 return True
59 def can_meld_into_agari(self):
60 """
61 Is melding into agari allowed with this strategy
62 :return: boolean
63 """
64 # By default, the logic is the following: if we have any
65 # non-suitable tiles, we can meld into agari state, because we'll
66 # throw them away after meld.
67 # Otherwise, there is no point.
68 for tile in self.player.tiles:
69 if not self.is_tile_suitable(tile):
70 return True
72 return False
74 def is_tile_suitable(self, tile):
75 """
76 Can tile be used for open hand strategy or not
77 :param tile: in 136 tiles format
78 :return: boolean
79 """
80 raise NotImplementedError()
82 def determine_what_to_discard(self, discard_options, hand, open_melds):
83 first_option = sorted(discard_options, key=lambda x: x.shanten)[0]
84 shanten = first_option.shanten
86 # for riichi we don't need to discard useful tiles
87 if shanten == 0 and not self.player.is_open_hand:
88 return discard_options
90 # mark all not suitable tiles as ready to discard
91 # even if they not should be discarded by uke-ire
92 for x in discard_options:
93 if not self.is_tile_suitable(x.tile_to_discard_136):
94 x.had_to_be_discarded = True
96 return discard_options
98 def try_to_call_meld(self, tile, is_kamicha_discard, new_tiles):
99 """
100 Determine should we call a meld or not.
101 If yes, it will return MeldPrint object and tile to discard
102 :param tile: 136 format tile
103 :param is_kamicha_discard: boolean
104 :param new_tiles:
105 :return: MeldPrint and DiscardOption objects
106 """
107 if self.player.in_riichi:
108 return None, None
110 closed_hand = self.player.closed_hand[:]
112 # we can't open hand anymore
113 if len(closed_hand) == 1:
114 return None, None
116 # we can't use this tile for our chosen strategy
117 if not self.is_tile_suitable(tile):
118 return None, None
120 discarded_tile = tile // 4
121 closed_hand_34 = TilesConverter.to_34_array(closed_hand + [tile])
123 combinations = []
124 first_index = 0
125 second_index = 0
126 if is_man(discarded_tile):
127 first_index = 0
128 second_index = 8
129 elif is_pin(discarded_tile):
130 first_index = 9
131 second_index = 17
132 elif is_sou(discarded_tile):
133 first_index = 18
134 second_index = 26
136 if second_index == 0:
137 # honor tiles
138 if closed_hand_34[discarded_tile] == 3:
139 combinations = [[[discarded_tile] * 3]]
140 else:
141 # to avoid not necessary calculations
142 # we can check only tiles around +-2 discarded tile
143 first_limit = discarded_tile - 2
144 if first_limit < first_index:
145 first_limit = first_index
147 second_limit = discarded_tile + 2
148 if second_limit > second_index:
149 second_limit = second_index
151 combinations = self.player.ai.hand_divider.find_valid_combinations(
152 closed_hand_34, first_limit, second_limit, True
153 )
155 if combinations:
156 combinations = combinations[0]
158 possible_melds = []
159 for best_meld_34 in combinations:
160 # we can call pon from everyone
161 if is_pon(best_meld_34) 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 chi only from left player
166 if is_chi(best_meld_34) and is_kamicha_discard and discarded_tile in best_meld_34:
167 if best_meld_34 not in possible_melds:
168 possible_melds.append(best_meld_34)
170 # we can call melds only with allowed tiles
171 validated_melds = []
172 for meld in possible_melds:
173 if (
174 self.is_tile_suitable(meld[0] * 4)
175 and self.is_tile_suitable(meld[1] * 4)
176 and self.is_tile_suitable(meld[2] * 4)
177 ):
178 validated_melds.append(meld)
179 possible_melds = validated_melds
181 if not possible_melds:
182 return None, None
184 chosen_meld_dict = self._find_best_meld_to_open(tile, possible_melds, new_tiles, closed_hand, tile)
185 # we didn't find a good discard candidate after open meld
186 if not chosen_meld_dict:
187 return None, None
189 selected_tile = chosen_meld_dict["discard_tile"]
190 meld = chosen_meld_dict["meld"]
192 shanten = selected_tile.shanten
193 had_to_be_called = self.meld_had_to_be_called(tile)
194 had_to_be_called = had_to_be_called or selected_tile.had_to_be_discarded
196 # each strategy can use their own value to min shanten number
197 if shanten > self.min_shanten:
198 self.player.logger.debug(
199 log.MELD_DEBUG,
200 "After meld shanten is too high for our strategy. Abort melding.",
201 )
202 return None, None
204 # sometimes we had to call tile, even if it will not improve our hand
205 # otherwise we can call only with improvements of shanten
206 if not had_to_be_called and shanten >= self.player.ai.shanten:
207 self.player.logger.debug(
208 log.MELD_DEBUG,
209 "Meld is not improving hand shanten. Abort melding.",
210 )
211 return None, None
213 if not self.validate_meld(chosen_meld_dict):
214 self.player.logger.debug(
215 log.MELD_DEBUG,
216 "Meld is suitable for strategy logic. Abort melding.",
217 )
218 return None, None
220 if not self.should_push_against_threats(chosen_meld_dict):
221 self.player.logger.debug(
222 log.MELD_DEBUG,
223 "Meld is too dangerous to call. Abort melding.",
224 )
225 return None, None
227 return meld, selected_tile
229 def should_push_against_threats(self, chosen_meld_dict) -> bool:
230 selected_tile = chosen_meld_dict["discard_tile"]
232 if selected_tile.shanten <= 1:
233 return True
235 threats = self.player.ai.defence.get_threatening_players()
236 if not threats:
237 return True
239 # don't open garbage hand against threats
240 if selected_tile.shanten >= 3:
241 return False
243 tile_136 = selected_tile.tile_to_discard_136
244 if len(threats) == 1:
245 threat_hand_cost = threats[0].get_assumed_hand_cost(tile_136)
246 # expensive threat
247 # and our hand is not good
248 # let's not open this
249 if threat_hand_cost >= 7700:
250 return False
251 else:
252 min_threat_hand_cost = min([x.get_assumed_hand_cost(tile_136) for x in threats])
253 # 2+ threats
254 # and they are not cheap
255 # so, let's skip opening of bad hand
256 if min_threat_hand_cost >= 5200:
257 return False
259 return True
261 def validate_meld(self, chosen_meld_dict):
262 """
263 In some cased we want additionally check that meld is suitable to the strategy
264 """
265 if self.player.is_open_hand:
266 return True
268 if not self.player.ai.placement.is_oorasu:
269 return True
271 # don't care about not enough cost if we are the dealer
272 if self.player.is_dealer:
273 return True
275 placement = self.player.ai.placement.get_current_placement()
276 if not placement:
277 return True
279 needed_cost = self.player.ai.placement.get_minimal_cost_needed_considering_west(placement=placement)
280 if needed_cost <= 1000:
281 return True
283 selected_tile = chosen_meld_dict["discard_tile"]
284 if selected_tile.ukeire == 0:
285 self.player.logger.debug(
286 log.MELD_DEBUG, "We need to get out of 4th place, but this meld leaves us with zero ukeire"
287 )
288 return False
290 logger_context = {
291 "placement": placement,
292 "meld": chosen_meld_dict,
293 "needed_cost": needed_cost,
294 }
296 if selected_tile.shanten == 0:
297 if not selected_tile.tempai_descriptor:
298 return True
300 # tempai has special logger context
301 logger_context = {
302 "placement": placement,
303 "meld": chosen_meld_dict,
304 "needed_cost": needed_cost,
305 "tempai_descriptor": selected_tile.tempai_descriptor,
306 }
308 if selected_tile.tempai_descriptor["hand_cost"]:
309 hand_cost = selected_tile.tempai_descriptor["hand_cost"]
310 else:
311 hand_cost = selected_tile.tempai_descriptor["cost_x_ukeire"] / selected_tile.ukeire
313 # optimistic condition - direct ron
314 if hand_cost * 2 < needed_cost:
315 self.player.logger.debug(
316 log.MELD_DEBUG, "No chance to comeback from 4th with this meld, so keep hand closed", logger_context
317 )
318 return False
319 elif selected_tile.shanten == 1:
320 if selected_tile.average_second_level_cost is None:
321 return True
323 # optimistic condition - direct ron
324 if selected_tile.average_second_level_cost * 2 < needed_cost:
325 self.player.logger.debug(
326 log.MELD_DEBUG, "No chance to comeback from 4th with this meld, so keep hand closed", logger_context
327 )
328 return False
329 else:
330 simple_han_scale = [0, 1000, 2000, 3900, 7700, 8000, 12000, 12000]
331 num_han = self.get_open_hand_han() + self.dora_count_total
332 if num_han < len(simple_han_scale):
333 hand_cost = simple_han_scale[num_han]
334 # optimistic condition - direct ron
335 if hand_cost * 2 < needed_cost:
336 self.player.logger.debug(
337 log.MELD_DEBUG,
338 "No chance to comeback from 4th with this meld, so keep hand closed",
339 logger_context,
340 )
341 return False
343 self.player.logger.debug(log.MELD_DEBUG, "This meld should allow us to comeback from 4th", logger_context)
344 return True
346 def meld_had_to_be_called(self, tile):
347 """
348 For special cases meld had to be called even if shanten number will not be increased
349 :param tile: in 136 tiles format
350 :return: boolean
351 """
352 return False
354 def calculate_dora_count(self, tiles_136):
355 self.dora_count_central = 0
356 self.dora_count_not_central = 0
357 self.aka_dora_count = 0
359 for tile_136 in tiles_136:
360 tile_34 = tile_136 // 4
362 dora_count = plus_dora(
363 tile_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora
364 )
366 if not dora_count:
367 continue
369 if is_honor(tile_34):
370 self.dora_count_not_central += dora_count
371 self.dora_count_honor += dora_count
372 elif is_terminal(tile_34):
373 self.dora_count_not_central += dora_count
374 else:
375 self.dora_count_central += dora_count
377 self.dora_count_central += self.aka_dora_count
378 self.dora_count_total = self.dora_count_central + self.dora_count_not_central
380 def _find_best_meld_to_open(self, call_tile_136, possible_melds, new_tiles, closed_hand, discarded_tile):
381 all_tiles_are_suitable = True
382 for tile_136 in closed_hand:
383 all_tiles_are_suitable &= self.is_tile_suitable(tile_136)
385 final_results = []
386 for meld_34 in possible_melds:
387 # in order to fully emulate the possible hand with meld, we save original melds state,
388 # modify player's melds and then restore original melds state after everything is done
389 melds_original = self.player.melds[:]
390 tiles_original = self.player.tiles[:]
392 tiles = self._find_meld_tiles(closed_hand, meld_34, discarded_tile)
393 meld = MeldPrint()
394 meld.type = is_chi(meld_34) and MeldPrint.CHI or MeldPrint.PON
395 meld.tiles = sorted(tiles)
397 self.player.logger.debug(
398 log.MELD_HAND, f"Hand: {self._format_hand_for_print(closed_hand, discarded_tile, self.player.melds)}"
399 )
401 # update player hand state to emulate new situation and choose what to discard
402 self.player.tiles = new_tiles[:]
403 self.player.add_called_meld(meld)
405 selected_tile = self.player.ai.hand_builder.choose_tile_to_discard(after_meld=True)
407 # restore original tiles and melds state
408 self.player.tiles = tiles_original
409 self.player.melds = melds_original
411 # we can't find a good discard candidate, so let's skip this
412 if not selected_tile:
413 self.player.logger.debug(log.MELD_DEBUG, "Can't find discard candidate after meld. Abort melding.")
414 continue
416 if not all_tiles_are_suitable and self.is_tile_suitable(selected_tile.tile_to_discard_136):
417 self.player.logger.debug(
418 log.MELD_DEBUG,
419 "We have tiles in our hand that are not suitable to current strategy, "
420 "but we are going to discard tile that we need. Abort melding.",
421 )
422 continue
424 call_tile_34 = call_tile_136 // 4
425 # we can't discard the same tile that we called
426 if selected_tile.tile_to_discard_34 == call_tile_34:
427 self.player.logger.debug(
428 log.MELD_DEBUG, "We can't discard same tile that we used for meld. Abort melding."
429 )
430 continue
432 # we can't discard tile from the other end of the same ryanmen that we called
433 if not is_honor(selected_tile.tile_to_discard_34) and meld.type == MeldPrint.CHI:
434 if is_sou(selected_tile.tile_to_discard_34) and is_sou(call_tile_34):
435 same_suit = True
436 elif is_man(selected_tile.tile_to_discard_34) and is_man(call_tile_34):
437 same_suit = True
438 elif is_pin(selected_tile.tile_to_discard_34) and is_pin(call_tile_34):
439 same_suit = True
440 else:
441 same_suit = False
443 if same_suit:
444 simplified_meld_0 = simplify(meld.tiles[0] // 4)
445 simplified_meld_1 = simplify(meld.tiles[1] // 4)
446 simplified_call = simplify(call_tile_34)
447 simplified_discard = simplify(selected_tile.tile_to_discard_34)
448 kuikae = False
449 if 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
453 elif simplified_discard == simplified_call + 3:
454 kuikae_set = [simplified_call + 1, simplified_call + 2]
455 if simplified_meld_0 in kuikae_set and simplified_meld_1 in kuikae_set:
456 kuikae = True
458 if kuikae:
459 tile_str = TilesConverter.to_one_line_string(
460 [selected_tile.tile_to_discard_136], print_aka_dora=self.player.table.has_aka_dora
461 )
462 self.player.logger.debug(
463 log.MELD_DEBUG,
464 f"Kuikae discard {tile_str} candidate. Abort melding.",
465 )
466 continue
468 final_results.append(
469 {
470 "discard_tile": selected_tile,
471 "meld_print": TilesConverter.to_one_line_string([meld_34[0] * 4, meld_34[1] * 4, meld_34[2] * 4]),
472 "meld": meld,
473 }
474 )
476 if not final_results:
477 self.player.logger.debug(log.MELD_DEBUG, "There are no good discards after melding.")
478 return None
480 final_results = sorted(
481 final_results,
482 key=lambda x: (x["discard_tile"].shanten, -x["discard_tile"].ukeire, x["discard_tile"].valuation),
483 )
485 self.player.logger.debug(
486 log.MELD_PREPARE,
487 "Tiles could be used for open meld",
488 context=final_results,
489 )
490 return final_results[0]
492 @staticmethod
493 def _find_meld_tiles(closed_hand, meld_34, discarded_tile):
494 discarded_tile_34 = discarded_tile // 4
495 meld_34_copy = meld_34[:]
496 closed_hand_copy = closed_hand[:]
498 meld_34_copy.remove(discarded_tile_34)
500 first_tile = TilesConverter.find_34_tile_in_136_array(meld_34_copy[0], closed_hand_copy)
501 closed_hand_copy.remove(first_tile)
503 second_tile = TilesConverter.find_34_tile_in_136_array(meld_34_copy[1], closed_hand_copy)
504 closed_hand_copy.remove(second_tile)
506 tiles = [first_tile, second_tile, discarded_tile]
508 return tiles
510 def _format_hand_for_print(self, tiles, new_tile, melds):
511 tiles_string = TilesConverter.to_one_line_string(tiles, print_aka_dora=self.player.table.has_aka_dora)
512 tile_string = TilesConverter.to_one_line_string([new_tile], print_aka_dora=self.player.table.has_aka_dora)
513 hand_string = f"{tiles_string} + {tile_string}"
514 hand_string += " [{}]".format(
515 ", ".join(
516 [
517 TilesConverter.to_one_line_string(x.tiles, print_aka_dora=self.player.table.has_aka_dora)
518 for x in melds
519 ]
520 )
521 )
522 return hand_string