Coverage for project/game/ai/defence/main.py : 88%

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 copy import copy
2from typing import List, Optional
4from game.ai.defence.enemy_analyzer import EnemyAnalyzer
5from game.ai.discard import DiscardOption
6from game.ai.helpers.defence import DangerBorder, TileDanger
7from game.ai.helpers.kabe import Kabe
8from game.ai.helpers.possible_forms import PossibleFormsAnalyzer
9from mahjong.tile import TilesConverter
10from mahjong.utils import is_honor, is_man, is_pin, is_sou, is_terminal, plus_dora, simplify
11from utils.general import is_dora_connector, is_tiles_same_suit
14class TileDangerHandler:
15 player = None
16 _analyzed_enemies: Optional[List[EnemyAnalyzer]] = None
17 _threats_cache: Optional[List[EnemyAnalyzer]] = None
19 def __init__(self, player):
20 self.player = player
21 self._analyzed_enemies = []
22 self._threats_cache = []
24 self.possible_forms_analyzer = PossibleFormsAnalyzer(player)
26 def calculate_tiles_danger(
27 self, discard_candidates: List[DiscardOption], enemy_analyzer: EnemyAnalyzer
28 ) -> List[DiscardOption]:
29 closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand)
31 safe_against_threat_34 = []
33 # First, add all genbutsu to the list
34 safe_against_threat_34.extend(list(set([x for x in enemy_analyzer.enemy.all_safe_tiles])))
36 # Then add tiles not suitable for yaku in enemy open hand
37 if enemy_analyzer.threat_reason.get("active_yaku"):
38 safe_against_yaku = set.intersection(
39 *[set(x.get_safe_tiles_34()) for x in enemy_analyzer.threat_reason.get("active_yaku")]
40 )
41 if safe_against_yaku:
42 safe_against_threat_34.extend(list(safe_against_yaku))
44 possible_forms = self.possible_forms_analyzer.calculate_possible_forms(enemy_analyzer.enemy.all_safe_tiles)
45 kabe_tiles = self.player.ai.kabe.find_all_kabe(closed_hand_34)
46 suji_tiles = self.player.ai.suji.find_suji([x.value for x in enemy_analyzer.enemy.discards])
47 for discard_option in discard_candidates:
48 tile_34 = discard_option.tile_to_discard_34
49 tile_136 = discard_option.tile_to_discard_136
50 number_of_revealed_tiles = self.player.number_of_revealed_tiles(tile_34, closed_hand_34)
52 # like 1-9 against tanyao etc.
53 if tile_34 in safe_against_threat_34:
54 self._update_discard_candidate(
55 tile_34,
56 discard_candidates,
57 enemy_analyzer.enemy.seat,
58 TileDanger.SAFE_AGAINST_THREATENING_HAND,
59 )
60 continue
62 # safe tiles that can be safe based on the table situation
63 if self.total_possible_forms_for_tile(possible_forms, tile_34) == 0:
64 self._update_discard_candidate(
65 tile_34,
66 discard_candidates,
67 enemy_analyzer.enemy.seat,
68 TileDanger.IMPOSSIBLE_WAIT,
69 )
70 continue
72 # honors
73 if is_honor(tile_34):
74 danger = self._process_danger_for_honor(enemy_analyzer, tile_34, number_of_revealed_tiles)
75 # terminals
76 elif is_terminal(tile_34):
77 danger = self._process_danger_for_terminal_tiles_and_kabe_suji(
78 enemy_analyzer, tile_34, number_of_revealed_tiles, kabe_tiles, suji_tiles
79 )
80 # 2-8 tiles
81 else:
82 danger = self._process_danger_for_2_8_tiles_suji_and_kabe(
83 enemy_analyzer, tile_34, number_of_revealed_tiles, suji_tiles, kabe_tiles
84 )
86 if danger:
87 self._update_discard_candidate(
88 tile_34,
89 discard_candidates,
90 enemy_analyzer.enemy.seat,
91 danger,
92 )
94 forms_count = possible_forms[tile_34]
95 self._update_discard_candidate(
96 tile_34,
97 discard_candidates,
98 enemy_analyzer.enemy.seat,
99 {
100 "value": self.possible_forms_analyzer.calculate_possible_forms_danger(forms_count),
101 "description": TileDanger.FORM_BONUS_DESCRIPTION,
102 "forms_count": forms_count,
103 },
104 )
106 # for ryanmen waits we also account for number of dangerous suji tiles
107 forms_ryanmen_count = forms_count[PossibleFormsAnalyzer.POSSIBLE_RYANMEN_SIDES]
108 if forms_ryanmen_count == 1:
109 self._update_discard_candidate(
110 tile_34,
111 discard_candidates,
112 enemy_analyzer.enemy.seat,
113 TileDanger.RYANMEN_BASE_SINGLE,
114 )
115 elif forms_ryanmen_count == 2:
116 self._update_discard_candidate(
117 tile_34,
118 discard_candidates,
119 enemy_analyzer.enemy.seat,
120 TileDanger.RYANMEN_BASE_DOUBLE,
121 )
123 if forms_ryanmen_count == 1 or forms_ryanmen_count == 2:
124 has_matagi = self._is_matagi_suji(enemy_analyzer, tile_34)
125 if has_matagi:
126 self._update_discard_candidate(
127 tile_34,
128 discard_candidates,
129 enemy_analyzer.enemy.seat,
130 TileDanger.BONUS_MATAGI_SUJI,
131 can_be_used_for_ryanmen=True,
132 )
134 has_aidayonken = self.is_aidayonken_pattern(enemy_analyzer, tile_34)
135 if has_aidayonken:
136 self._update_discard_candidate(
137 tile_34,
138 discard_candidates,
139 enemy_analyzer.enemy.seat,
140 TileDanger.BONUS_AIDAYONKEN,
141 can_be_used_for_ryanmen=True,
142 )
144 early_danger_bonus = self._get_early_danger_bonus(enemy_analyzer, tile_34, has_matagi or has_aidayonken)
145 if early_danger_bonus is not None:
146 self._update_discard_candidate(
147 tile_34,
148 discard_candidates,
149 enemy_analyzer.enemy.seat,
150 early_danger_bonus,
151 can_be_used_for_ryanmen=True,
152 )
154 self._update_discard_candidate(
155 tile_34,
156 discard_candidates,
157 enemy_analyzer.enemy.seat,
158 TileDanger.make_unverified_suji_coeff(enemy_analyzer.unverified_suji_coeff),
159 can_be_used_for_ryanmen=True,
160 )
162 if is_dora_connector(tile_136, self.player.table.dora_indicators):
163 self._update_discard_candidate(
164 tile_34,
165 discard_candidates,
166 enemy_analyzer.enemy.seat,
167 TileDanger.DORA_CONNECTOR_BONUS,
168 can_be_used_for_ryanmen=True,
169 )
171 dora_count = plus_dora(
172 tile_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora
173 )
175 if dora_count > 0:
176 danger = copy(TileDanger.DORA_BONUS)
177 danger["value"] = dora_count * danger["value"]
178 danger["dora_count"] = dora_count
179 self._update_discard_candidate(
180 tile_34,
181 discard_candidates,
182 enemy_analyzer.enemy.seat,
183 danger,
184 )
186 if enemy_analyzer.threat_reason.get("active_yaku"):
187 for yaku_analyzer in enemy_analyzer.threat_reason.get("active_yaku"):
188 bonus_danger = yaku_analyzer.get_bonus_danger(tile_136, number_of_revealed_tiles)
189 for danger in bonus_danger:
190 self._update_discard_candidate(
191 tile_34,
192 discard_candidates,
193 enemy_analyzer.enemy.seat,
194 danger,
195 )
197 return discard_candidates
199 def calculate_danger_borders(self, discard_options, threatening_player, all_threatening_players):
200 min_shanten = min([x.shanten for x in discard_options])
202 placement_adjustment = self.player.ai.placement.get_allowed_danger_modifier()
203 for discard_option in discard_options:
204 danger_border = DangerBorder.BETAORI
205 hand_weighted_cost = 0
206 tune = 0
207 shanten = discard_option.shanten
208 tile_136 = discard_option.tile_to_discard_136
210 if discard_option.danger.get_total_danger_for_player(threatening_player.enemy.seat) == 0:
211 threatening_player_hand_cost = 0
212 else:
213 threatening_player_hand_cost = threatening_player.get_assumed_hand_cost(
214 tile_136, discard_option.danger.can_be_used_for_ryanmen
215 )
217 # fast path: we don't need to calculate all the stuff if this tile is safe against this enemy
218 if threatening_player_hand_cost == 0:
219 discard_option.danger.set_danger_border(
220 threatening_player.enemy.seat, DangerBorder.IGNORE, hand_weighted_cost, threatening_player_hand_cost
221 )
222 continue
224 if discard_option.shanten == 0:
225 hand_weighted_cost = self.player.ai.estimate_weighted_mean_hand_value(discard_option)
227 # we are not ready to push with hand that doesn't have chances to win
228 # or to get ryukoku payments
229 if hand_weighted_cost == 0:
230 discard_option.danger.set_danger_border(
231 threatening_player.enemy.seat,
232 DangerBorder.BETAORI,
233 hand_weighted_cost,
234 threatening_player_hand_cost,
235 )
236 continue
238 discard_option.danger.weighted_cost = hand_weighted_cost
239 cost_ratio = (hand_weighted_cost / threatening_player_hand_cost) * 100
240 tune = self.player.config.TUNE_DANGER_BORDER_TEMPAI_VALUE
242 if self.player.ai.placement.must_push(
243 all_threatening_players,
244 discard_option.tile_to_discard_136,
245 num_shanten=0,
246 tempai_cost=hand_weighted_cost,
247 ):
248 danger_border = DangerBorder.IGNORE
249 else:
250 # good wait
251 if discard_option.ukeire >= 6:
252 if cost_ratio >= 100:
253 danger_border = DangerBorder.IGNORE
254 elif cost_ratio >= 70:
255 danger_border = DangerBorder.IGNORE
256 elif cost_ratio >= 50:
257 danger_border = DangerBorder.EXTREME
258 elif cost_ratio >= 30:
259 danger_border = DangerBorder.VERY_HIGH
260 else:
261 danger_border = DangerBorder.MEDIUM
262 # moderate wait
263 elif discard_option.ukeire >= 4:
264 if cost_ratio >= 400:
265 danger_border = DangerBorder.IGNORE
266 elif cost_ratio >= 200:
267 danger_border = DangerBorder.IGNORE
268 elif cost_ratio >= 100:
269 danger_border = DangerBorder.IGNORE
270 elif cost_ratio >= 70:
271 danger_border = DangerBorder.EXTREME
272 elif cost_ratio >= 50:
273 danger_border = DangerBorder.HIGH
274 elif cost_ratio >= 30:
275 danger_border = DangerBorder.UPPER_MEDIUM
276 else:
277 danger_border = DangerBorder.LOWER_MEDIUM
278 # weak wait
279 elif discard_option.ukeire >= 2:
280 if cost_ratio >= 400:
281 danger_border = DangerBorder.IGNORE
282 elif cost_ratio >= 200:
283 danger_border = DangerBorder.IGNORE
284 elif cost_ratio >= 100:
285 danger_border = DangerBorder.EXTREME
286 elif cost_ratio >= 70:
287 danger_border = DangerBorder.VERY_HIGH
288 elif cost_ratio >= 50:
289 danger_border = DangerBorder.UPPER_MEDIUM
290 elif cost_ratio >= 30:
291 danger_border = DangerBorder.MEDIUM
292 else:
293 danger_border = DangerBorder.UPPER_LOW
294 # waiting for 1 tile basically
295 else:
296 if cost_ratio >= 400:
297 danger_border = DangerBorder.IGNORE
298 elif cost_ratio >= 200:
299 danger_border = DangerBorder.EXTREME
300 elif cost_ratio >= 100:
301 danger_border = DangerBorder.HIGH
302 elif cost_ratio >= 50:
303 danger_border = DangerBorder.MEDIUM
304 else:
305 danger_border = DangerBorder.UPPER_LOW
307 if discard_option.shanten == 1:
308 tune = self.player.config.TUNE_DANGER_BORDER_1_SHANTEN_VALUE
310 # FIXME: temporary solution to avoid too much ukeire2 calculation
311 if min_shanten == 0:
312 hand_weighted_cost = 2000
313 else:
314 hand_weighted_cost = discard_option.average_second_level_cost
316 # never push with zero chance to win
317 # FIXME: we may actually want to push it for tempai in ryukoku, so reconsider
318 if not hand_weighted_cost:
319 discard_option.danger.set_danger_border(
320 threatening_player.enemy.seat,
321 DangerBorder.BETAORI,
322 hand_weighted_cost,
323 threatening_player_hand_cost,
324 )
325 continue
327 discard_option.danger.weighted_cost = int(hand_weighted_cost)
328 cost_ratio = (hand_weighted_cost / threatening_player_hand_cost) * 100
329 average_tempai_waits = discard_option.average_second_level_waits
331 if self.player.ai.placement.must_push(
332 all_threatening_players,
333 discard_option.tile_to_discard_136,
334 num_shanten=1,
335 tempai_cost=hand_weighted_cost,
336 ):
337 danger_border = DangerBorder.IGNORE
338 else:
339 # lots of ukeire
340 if discard_option.ukeire >= 32 and average_tempai_waits >= 6:
341 if cost_ratio >= 400:
342 danger_border = DangerBorder.IGNORE
343 elif cost_ratio >= 200:
344 danger_border = DangerBorder.EXTREME
345 elif cost_ratio >= 100:
346 danger_border = DangerBorder.VERY_HIGH
347 elif cost_ratio >= 50:
348 danger_border = DangerBorder.MEDIUM
349 elif cost_ratio >= 20:
350 danger_border = DangerBorder.UPPER_LOW
351 else:
352 danger_border = DangerBorder.EXTREMELY_LOW
353 # very good ukeire
354 elif discard_option.ukeire >= 20 and average_tempai_waits >= 6:
355 if cost_ratio >= 400:
356 danger_border = DangerBorder.IGNORE
357 elif cost_ratio >= 200:
358 danger_border = DangerBorder.EXTREME
359 elif cost_ratio >= 100:
360 danger_border = DangerBorder.VERY_HIGH
361 elif cost_ratio >= 50:
362 danger_border = DangerBorder.LOWER_MEDIUM
363 elif cost_ratio >= 20:
364 danger_border = DangerBorder.LOW
365 else:
366 danger_border = DangerBorder.EXTREMELY_LOW
367 # good ukeire
368 elif discard_option.ukeire >= 12 and average_tempai_waits >= 4:
369 if cost_ratio >= 400:
370 danger_border = DangerBorder.VERY_HIGH
371 elif cost_ratio >= 200:
372 danger_border = DangerBorder.HIGH
373 elif cost_ratio >= 100:
374 danger_border = DangerBorder.UPPER_MEDIUM
375 elif cost_ratio >= 50:
376 danger_border = DangerBorder.UPPER_LOW
377 elif cost_ratio >= 20:
378 danger_border = DangerBorder.VERY_LOW
379 else:
380 danger_border = DangerBorder.BETAORI
381 # mediocre ukeire
382 elif discard_option.ukeire >= 7 and average_tempai_waits >= 2:
383 if cost_ratio >= 400:
384 danger_border = DangerBorder.HIGH
385 elif cost_ratio >= 200:
386 danger_border = DangerBorder.UPPER_MEDIUM
387 elif cost_ratio >= 100:
388 danger_border = DangerBorder.LOWER_MEDIUM
389 elif cost_ratio >= 50:
390 danger_border = DangerBorder.VERY_LOW
391 elif cost_ratio >= 20:
392 danger_border = DangerBorder.LOWEST
393 else:
394 danger_border = DangerBorder.BETAORI
395 # very low ukeire
396 elif discard_option.ukeire >= 3 and average_tempai_waits >= 1:
397 if cost_ratio >= 400:
398 danger_border = DangerBorder.MEDIUM
399 elif cost_ratio >= 200:
400 danger_border = DangerBorder.UPPER_LOW
401 elif cost_ratio >= 100:
402 danger_border = DangerBorder.VERY_LOW
403 elif cost_ratio >= 50:
404 danger_border = DangerBorder.LOWEST
405 else:
406 danger_border = DangerBorder.BETAORI
407 # little to no ukeire
408 else:
409 danger_border = DangerBorder.BETAORI
411 if discard_option.shanten == 2:
412 tune = self.player.config.TUNE_DANGER_BORDER_2_SHANTEN_VALUE
414 if self.player.is_dealer:
415 scale = [0, 1000, 2900, 5800, 7700, 12000, 18000, 18000, 24000, 24000, 48000]
416 else:
417 scale = [0, 1000, 2000, 3900, 5200, 8000, 12000, 12000, 16000, 16000, 32000]
419 if self.player.is_open_hand:
420 # FIXME: each strategy should have a han value, we should use it instead
421 han = 1
422 else:
423 # TODO: try to estimate yaku chances for closed hand
424 han = 1
426 dora_count = sum(
427 [
428 plus_dora(x, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora)
429 for x in self.player.tiles
430 ]
431 )
433 han += dora_count
435 hand_weighted_cost = scale[min(han, len(scale) - 1)]
437 discard_option.danger.weighted_cost = int(hand_weighted_cost)
438 cost_ratio = (hand_weighted_cost / threatening_player_hand_cost) * 100
440 if self.player.ai.placement.must_push(
441 all_threatening_players,
442 discard_option.tile_to_discard_136,
443 num_shanten=2,
444 tempai_cost=hand_weighted_cost,
445 ):
446 danger_border = DangerBorder.IGNORE
447 else:
448 # lots of ukeire
449 if discard_option.ukeire >= 40:
450 if cost_ratio >= 400:
451 danger_border = DangerBorder.HIGH
452 elif cost_ratio >= 200:
453 danger_border = DangerBorder.MEDIUM
454 elif cost_ratio >= 100:
455 danger_border = DangerBorder.EXTREMELY_LOW
456 else:
457 danger_border = DangerBorder.BETAORI
458 # very good ukeire
459 elif discard_option.ukeire >= 20:
460 if cost_ratio >= 400:
461 danger_border = DangerBorder.UPPER_MEDIUM
462 elif cost_ratio >= 200:
463 danger_border = DangerBorder.LOW
464 elif cost_ratio >= 100:
465 danger_border = DangerBorder.LOWEST
466 else:
467 danger_border = DangerBorder.BETAORI
468 # mediocre ukeire or worse
469 else:
470 danger_border = DangerBorder.BETAORI
472 # if we could have chosen tempai, pushing 1 or more shanten is usually
473 # a pretty bad idea, so tune down
474 if discard_option.shanten != 0 and min_shanten == 0:
475 danger_border = DangerBorder.tune_down(danger_border, 2)
477 # depending on our placement we may want to be more defensive or more offensive
478 tune += placement_adjustment
479 danger_border = DangerBorder.tune(danger_border, tune)
481 # if it's late there are generally less reasons to be aggressive
482 danger_border = DangerBorder.tune_for_round(self.player, danger_border, shanten)
484 discard_option.danger.set_danger_border(
485 threatening_player.enemy.seat, danger_border, hand_weighted_cost, threatening_player_hand_cost
486 )
487 return discard_options
489 def get_threatening_players(self, from_cache: bool = True) -> List[EnemyAnalyzer]:
490 if from_cache and self._threats_cache is not None:
491 return self._threats_cache
493 result = []
494 for player in self.analyzed_enemies:
495 if player.is_threatening:
496 result.append(player)
498 return result
500 def erase_threats_cache(self):
501 self._threats_cache = None
503 def mark_tiles_danger_for_threats(self, discard_options):
504 threatening_players = self.get_threatening_players()
505 for threatening_player in threatening_players:
506 discard_options = self.calculate_tiles_danger(discard_options, threatening_player)
507 discard_options = self.calculate_danger_borders(discard_options, threatening_player, threatening_players)
508 return discard_options, threatening_players
510 def total_possible_forms_for_tile(self, possible_forms, tile_34):
511 forms_count = possible_forms[tile_34]
512 assert forms_count is not None
513 return self.possible_forms_analyzer.calculate_possible_forms_total(forms_count)
515 @property
516 def analyzed_enemies(self):
517 if self._analyzed_enemies:
518 return self._analyzed_enemies
519 self._analyzed_enemies = [EnemyAnalyzer(enemy) for enemy in self.player.ai.enemy_players]
520 return self._analyzed_enemies
522 def _process_danger_for_terminal_tiles_and_kabe_suji(
523 self, enemy_analyzer, tile_34, number_of_revealed_tiles, kabe_tiles, suji_tiles
524 ):
525 have_strong_kabe = [x for x in kabe_tiles if tile_34 == x["tile"] and x["type"] == Kabe.STRONG_KABE]
526 if have_strong_kabe:
527 if enemy_analyzer.enemy.is_open_hand:
528 if number_of_revealed_tiles == 1:
529 return TileDanger.SHONPAI_KABE_STRONG_OPEN_HAND
530 else:
531 return TileDanger.NON_SHONPAI_KABE_STRONG_OPEN_HAND
532 else:
533 if number_of_revealed_tiles == 1:
534 return TileDanger.SHONPAI_KABE_STRONG
535 else:
536 return TileDanger.NON_SHONPAI_KABE_STRONG
538 if tile_34 in suji_tiles:
539 if enemy_analyzer.enemy.is_open_hand:
540 if number_of_revealed_tiles == 1:
541 return TileDanger.SUJI_19_SHONPAI_OPEN_HAND
542 else:
543 return TileDanger.SUJI_19_NOT_SHONPAI_OPEN_HAND
544 else:
545 if number_of_revealed_tiles == 1:
546 return TileDanger.SUJI_19_SHONPAI
547 else:
548 return TileDanger.SUJI_19_NOT_SHONPAI
550 return None
552 def _process_danger_for_2_8_tiles_suji_and_kabe(
553 self, enemy_analyzer, tile_34, number_of_revealed_tiles, suji_tiles, kabe_tiles
554 ):
555 have_strong_kabe = [x for x in kabe_tiles if tile_34 == x["tile"] and x["type"] == Kabe.STRONG_KABE]
556 if have_strong_kabe:
557 if enemy_analyzer.enemy.is_open_hand:
558 if number_of_revealed_tiles == 1:
559 return TileDanger.SHONPAI_KABE_STRONG_OPEN_HAND
560 else:
561 return TileDanger.NON_SHONPAI_KABE_STRONG_OPEN_HAND
562 else:
563 if number_of_revealed_tiles == 1:
564 return TileDanger.SHONPAI_KABE_STRONG
565 else:
566 return TileDanger.NON_SHONPAI_KABE_STRONG
568 have_weak_kabe = [x for x in kabe_tiles if tile_34 == x["tile"] and x["type"] == Kabe.WEAK_KABE]
569 if have_weak_kabe:
570 if enemy_analyzer.enemy.is_open_hand:
571 if number_of_revealed_tiles == 1:
572 return TileDanger.SHONPAI_KABE_WEAK_OPEN_HAND
573 else:
574 return TileDanger.NON_SHONPAI_KABE_WEAK_OPEN_HAND
575 else:
576 if number_of_revealed_tiles == 1:
577 return TileDanger.SHONPAI_KABE_WEAK
578 else:
579 return TileDanger.NON_SHONPAI_KABE_WEAK
581 # only consider suji if there is no kabe
582 have_suji = [x for x in suji_tiles if tile_34 == x]
583 if have_suji:
584 if enemy_analyzer.enemy.riichi_tile_136 is not None:
585 enemy_riichi_tile_34 = enemy_analyzer.enemy.riichi_tile_136 // 4
586 riichi_on_suji = [x for x in suji_tiles if enemy_riichi_tile_34 == x]
588 # if it's 2378, then check if riichi was on suji tile
589 if simplify(enemy_riichi_tile_34) == 4 and (simplify(tile_34) == 1 or simplify(tile_34) == 7):
590 return TileDanger.SUJI_28_ON_RIICHI
592 if simplify(tile_34) == 2 or simplify(tile_34) == 6:
593 if 3 <= simplify(enemy_riichi_tile_34) <= 5 and riichi_on_suji:
594 return TileDanger.SUJI_37_ON_RIICHI
595 elif enemy_analyzer.enemy.is_open_hand:
596 return TileDanger.SUJI_OPEN_HAND
598 return TileDanger.SUJI
600 return None
602 def _process_danger_for_honor(self, enemy_analyzer, tile_34, number_of_revealed_tiles):
603 danger = None
604 number_of_yakuhai = enemy_analyzer.enemy.valued_honors.count(tile_34)
606 if len(enemy_analyzer.enemy.discards) <= 6:
607 if number_of_revealed_tiles == 1:
608 if number_of_yakuhai == 0:
609 danger = TileDanger.NON_YAKUHAI_HONOR_SHONPAI_EARLY
610 if number_of_yakuhai == 1:
611 danger = TileDanger.YAKUHAI_HONOR_SHONPAI_EARLY
612 if number_of_yakuhai == 2:
613 danger = TileDanger.DOUBLE_YAKUHAI_HONOR_SHONPAI_EARLY
615 if number_of_revealed_tiles == 2:
616 if number_of_yakuhai == 0:
617 danger = TileDanger.NON_YAKUHAI_HONOR_SECOND_EARLY
618 if number_of_yakuhai == 1:
619 danger = TileDanger.YAKUHAI_HONOR_SECOND_EARLY
620 if number_of_yakuhai == 2:
621 danger = TileDanger.DOUBLE_YAKUHAI_HONOR_SECOND_EARLY
622 elif len(enemy_analyzer.enemy.discards) <= 12:
623 if number_of_revealed_tiles == 1:
624 if number_of_yakuhai == 0:
625 danger = TileDanger.NON_YAKUHAI_HONOR_SHONPAI_MID
626 if number_of_yakuhai == 1:
627 danger = TileDanger.YAKUHAI_HONOR_SHONPAI_MID
628 if number_of_yakuhai == 2:
629 danger = TileDanger.DOUBLE_YAKUHAI_HONOR_SHONPAI_MID
631 if number_of_revealed_tiles == 2:
632 if number_of_yakuhai == 0:
633 danger = TileDanger.NON_YAKUHAI_HONOR_SECOND_MID
634 if number_of_yakuhai == 1:
635 danger = TileDanger.YAKUHAI_HONOR_SECOND_MID
636 if number_of_yakuhai == 2:
637 danger = TileDanger.DOUBLE_YAKUHAI_HONOR_SECOND_MID
638 else:
639 if number_of_revealed_tiles == 1:
640 if number_of_yakuhai == 0:
641 danger = TileDanger.NON_YAKUHAI_HONOR_SHONPAI_LATE
642 if number_of_yakuhai == 1:
643 danger = TileDanger.YAKUHAI_HONOR_SHONPAI_LATE
644 if number_of_yakuhai == 2:
645 danger = TileDanger.DOUBLE_YAKUHAI_HONOR_SHONPAI_LATE
647 if number_of_revealed_tiles == 2:
648 if number_of_yakuhai == 0:
649 danger = TileDanger.NON_YAKUHAI_HONOR_SECOND_LATE
650 if number_of_yakuhai == 1:
651 danger = TileDanger.YAKUHAI_HONOR_SECOND_LATE
652 if number_of_yakuhai == 2:
653 danger = TileDanger.DOUBLE_YAKUHAI_HONOR_SECOND_LATE
655 if number_of_revealed_tiles == 3:
656 danger = TileDanger.HONOR_THIRD
658 return danger
660 def _update_discard_candidate(
661 self, tile_34, discard_candidates, player_seat, danger, can_be_used_for_ryanmen=False
662 ):
663 for discard_candidate in discard_candidates:
664 if discard_candidate.tile_to_discard_34 == tile_34:
665 if can_be_used_for_ryanmen:
666 discard_candidate.danger.can_be_used_for_ryanmen = can_be_used_for_ryanmen
668 # we found safe tile, in that case we can ignore all other metrics
669 if TileDanger.is_safe(danger):
670 discard_candidate.danger.clear_danger(player_seat)
672 # let's put danger metrics to the tile only if we are not yet sure that tile is already safe
673 is_known_to_be_safe = (
674 len([x for x in discard_candidate.danger.get_danger_reasons(player_seat) if TileDanger.is_safe(x)])
675 > 0
676 )
677 if not is_known_to_be_safe:
678 discard_candidate.danger.set_danger(player_seat, danger)
680 def is_aidayonken_pattern(self, enemy_analyzer, tile_analyze_34):
681 discards = enemy_analyzer.enemy_discards_until_all_tsumogiri
682 discards_34 = [x.value // 4 for x in discards]
684 patterns_config = [
685 {
686 "pattern": [1, 6],
687 "danger": [2, 5],
688 },
689 {
690 "pattern": [2, 7],
691 "danger": [3, 6],
692 },
693 {
694 "pattern": [3, 8],
695 "danger": [4, 7],
696 },
697 {
698 "pattern": [4, 9],
699 "danger": [5, 8],
700 },
701 ]
703 for is_suit in [is_pin, is_sou, is_man]:
704 if not is_suit(tile_analyze_34):
705 continue
707 same_suit_simple_discards = []
708 for discard_34 in discards_34:
709 if is_suit(discard_34):
710 # +1 here to make it easier to read
711 same_suit_simple_discards.append(simplify(discard_34) + 1)
713 # +1 here to make it easier to read
714 tile_analyze_simplified = simplify(tile_analyze_34) + 1
716 for pattern_config in patterns_config:
717 has_pattern = (
718 list(set(same_suit_simple_discards) & set(pattern_config["pattern"])) == pattern_config["pattern"]
719 )
720 if not has_pattern:
721 continue
723 has_suji_in_discard = len(list(set(same_suit_simple_discards) & set(pattern_config["danger"]))) != 0
724 # we found aidayonken pattern in the discard
725 # and aidayonken danger tiles are not in the discard
726 # in that case we can increase danger for them
727 if not has_suji_in_discard and tile_analyze_simplified in pattern_config["danger"]:
728 return True
730 return False
732 def _is_matagi_suji(self, enemy_analyzer, tile_analyze_34):
733 discards = enemy_analyzer.enemy.discards
734 discards_34 = [x.value // 4 for x in enemy_analyzer.enemy.discards]
736 # too early to check matagi suji
737 if len(discards) <= 5:
738 return False
739 # on middle stage check matagi pattern only for one latest discard
740 elif len(discards) <= 9:
741 latest_discards = [x for x in discards if not x.is_tsumogiri][-1:]
742 else:
743 # on late stage check matagi pattern for two latest discards
744 latest_discards = [x for x in discards if not x.is_tsumogiri][-2:]
746 latest_discards_34 = [x.value // 4 for x in latest_discards]
747 # make sure that these discards are unique
748 latest_discards_34 = list(set(latest_discards_34))
750 matagi_patterns_config = [
751 {"tile": 2, "dangers": [[1, 4]]},
752 {"tile": 3, "dangers": [[1, 4], [2, 5]]},
753 {"tile": 4, "dangers": [[2, 5], [3, 6]]},
754 {"tile": 5, "dangers": [[3, 6], [4, 7]]},
755 {"tile": 6, "dangers": [[4, 7], [5, 8]]},
756 {"tile": 7, "dangers": [[5, 8], [6, 9]]},
757 {"tile": 8, "dangers": [[6, 9]]},
758 ]
760 for enemy_discard_34 in latest_discards_34:
761 if not is_tiles_same_suit(enemy_discard_34, tile_analyze_34):
762 continue
764 # +1 here to make it easier read matagi patterns
765 enemy_discard_simplified = simplify(enemy_discard_34) + 1
766 tile_analyze_simplified = simplify(tile_analyze_34) + 1
768 for matagi_pattern_config in matagi_patterns_config:
769 if matagi_pattern_config["tile"] != enemy_discard_simplified:
770 continue
772 same_suit_simple_discards = []
773 for is_suit in [is_pin, is_sou, is_man]:
774 if not is_suit(tile_analyze_34):
775 continue
777 same_suit_simple_discards = []
778 for discard_34 in discards_34:
779 if is_suit(discard_34):
780 # +1 here to make it easier to read
781 same_suit_simple_discards.append(simplify(discard_34) + 1)
783 for danger in matagi_pattern_config["dangers"]:
785 has_suji_in_discard = len(list(set(same_suit_simple_discards) & set(danger))) != 0
786 if not has_suji_in_discard and tile_analyze_simplified in danger:
787 return True
789 return False
791 def _get_early_danger_bonus(self, enemy_analyzer, tile_analyze_34, has_other_danger_bonus):
792 discards = enemy_analyzer.enemy_discards_until_all_tsumogiri
793 discards_34 = [x.value // 4 for x in discards]
795 assert not is_honor(tile_analyze_34)
796 # +1 here to make it easier to read
797 tile_analyze_simplified = simplify(tile_analyze_34) + 1
798 # we only those border tiles
799 if tile_analyze_simplified not in [1, 2, 8, 9]:
800 return None
802 # too early to make statements
803 if len(discards_34) <= 5:
804 return None
806 central_discards_34 = [x for x in discards_34 if not is_honor(x) and not is_terminal(x)]
807 # also too early to make statements
808 if len(central_discards_34) <= 3:
809 return None
811 # we also want to check how many non-tsumogiri tiles there were after those discards
812 latest_discards_34 = [x.value // 4 for x in discards if not x.is_tsumogiri][-3:]
813 if len(latest_discards_34) != 3:
814 return None
816 # no more than 3, but we expect at least 3 non-central tiles after that one for pattern to matter
817 num_early_discards = min(len(central_discards_34) - 3, 3)
818 first_central_discards_34 = central_discards_34[:num_early_discards]
820 patterns_config = []
821 if not has_other_danger_bonus:
822 # patterns lowering danger has higher priority in case they are possible
823 # +1 implied here to make it easier to read
824 # order is important, as 28 priority pattern is higher than 37 one
825 patterns_config.extend(
826 [
827 {
828 "pattern": 2,
829 "danger": [1],
830 "bonus": TileDanger.BONUS_EARLY_28,
831 },
832 {
833 "pattern": 8,
834 "danger": [9],
835 "bonus": TileDanger.BONUS_EARLY_28,
836 },
837 {
838 "pattern": 3,
839 "danger": [1, 2],
840 "bonus": TileDanger.BONUS_EARLY_37,
841 },
842 {
843 "pattern": 7,
844 "danger": [8, 9],
845 "bonus": TileDanger.BONUS_EARLY_37,
846 },
847 ]
848 )
849 # patterns increasing danger have lower priority, but are always applied
850 patterns_config.extend(
851 [
852 {
853 "pattern": 5,
854 "danger": [1, 9],
855 "bonus": TileDanger.BONUS_EARLY_5,
856 },
857 ]
858 )
860 # we return the first pattern we see
861 for enemy_discard_34 in first_central_discards_34:
862 # being also discarded late from hand kinda ruins our previous logic, so don't modify danger in that case
863 if enemy_discard_34 in latest_discards_34:
864 continue
866 if not is_tiles_same_suit(enemy_discard_34, tile_analyze_34):
867 continue
869 # +1 here to make it easier read matagi patterns
870 enemy_discard_simplified = simplify(enemy_discard_34) + 1
871 for pattern_config in patterns_config:
872 has_pattern = enemy_discard_simplified == pattern_config["pattern"]
873 if not has_pattern:
874 continue
876 if tile_analyze_simplified in pattern_config["danger"]:
877 return pattern_config["bonus"]
879 return None