Coverage for project/game/ai/defence/enemy_analyzer.py : 94%

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
3from game.ai.defence.yaku_analyzer.atodzuke import AtodzukeAnalyzer
4from game.ai.defence.yaku_analyzer.chinitsu import ChinitsuAnalyzer
5from game.ai.defence.yaku_analyzer.honitsu import HonitsuAnalyzer
6from game.ai.defence.yaku_analyzer.tanyao import TanyaoAnalyzer
7from game.ai.defence.yaku_analyzer.toitoi import ToitoiAnalyzer
8from game.ai.defence.yaku_analyzer.yakuhai import YakuhaiAnalyzer
9from game.ai.helpers.defence import EnemyDanger, TileDanger
10from game.ai.helpers.possible_forms import PossibleFormsAnalyzer
11from game.ai.statistics_collector import StatisticsCollector
12from mahjong.meld import Meld
13from mahjong.tile import TilesConverter
14from mahjong.utils import is_honor, is_terminal, plus_dora
15from utils.general import separate_tiles_by_suits
18class EnemyAnalyzer:
19 enemy = None
20 threat_reason = None
22 RIICHI_COST_SCALE = [2000, 3900, 5200, 8000, 8000, 12000, 12000, 16000, 16000, 32000]
23 RIICHI_DEALER_COST_SCALE = [2900, 5800, 7700, 12000, 12000, 18000, 18000, 24000, 24000, 48000]
25 def __init__(self, player):
26 self.enemy = player
27 self.table = player.table
29 # is our bot
30 self.main_player = self.table.player
31 self.possible_forms_analyzer = PossibleFormsAnalyzer(self.main_player)
33 def serialize(self):
34 return {"seat": self.enemy.seat, "threat_reason": self.threat_reason}
36 @property
37 def enemy_discards_until_all_tsumogiri(self):
38 """
39 Return all enemy discards including the last one from the hand but not further
40 """
41 discards = self.enemy.discards
43 if not discards:
44 return []
46 discards_from_hand = [x for x in discards if not x.is_tsumogiri]
47 if not discards_from_hand:
48 return []
50 last_from_hand = discards_from_hand[-1]
51 index_of_last_from_hand = discards.index(last_from_hand)
53 return discards[: index_of_last_from_hand + 1]
55 @property
56 def in_tempai(self) -> bool:
57 """
58 Try to detect is user in tempai or not
59 """
60 # simplest case, user in riichi
61 if self.enemy.in_riichi:
62 return True
64 if len(self.enemy.melds) == 4:
65 return True
67 return False
69 @property
70 def is_threatening(self) -> bool:
71 """
72 We are trying to determine other players current threat
73 """
74 round_step = len(self.enemy.discards)
76 if self.enemy.in_riichi:
77 self._create_danger_reason(EnemyDanger.THREAT_RIICHI, round_step=round_step)
78 return True
80 melds = self.enemy.melds
81 # we can't analyze closed hands for now
82 if not melds:
83 return False
85 active_yaku = []
86 sure_han = 0
88 yakuhai_analyzer = YakuhaiAnalyzer(self.enemy)
89 if yakuhai_analyzer.is_yaku_active():
90 active_yaku.append(yakuhai_analyzer)
91 sure_han = yakuhai_analyzer.melds_han()
93 yaku_analyzers = [
94 ChinitsuAnalyzer(self.enemy),
95 HonitsuAnalyzer(self.enemy),
96 ToitoiAnalyzer(self.enemy),
97 TanyaoAnalyzer(self.enemy),
98 ]
100 for x in yaku_analyzers:
101 if x.is_yaku_active():
102 active_yaku.append(x)
104 if not active_yaku:
105 active_yaku.append(AtodzukeAnalyzer(self.enemy))
106 sure_han = 1
108 # FIXME: probably our approach here should be refactored and we should not care about cost
109 if not sure_han:
110 main_yaku = [x for x in active_yaku if not x.is_absorbed(active_yaku)]
111 if main_yaku:
112 sure_han = main_yaku[0].melds_han()
113 else:
114 sure_han = 1
116 meld_tiles = self.enemy.meld_tiles
117 dora_count = sum(
118 [plus_dora(x, self.table.dora_indicators, add_aka_dora=self.table.has_aka_dora) for x in meld_tiles]
119 )
120 sure_han += dora_count
122 if len(melds) == 1 and round_step > 5 and sure_han >= 4:
123 self._create_danger_reason(
124 EnemyDanger.THREAT_OPEN_HAND_AND_MULTIPLE_DORA, melds, dora_count, active_yaku, round_step
125 )
126 return True
128 if len(melds) >= 2 and round_step > 4 and sure_han >= 3:
129 self._create_danger_reason(
130 EnemyDanger.THREAT_EXPENSIVE_OPEN_HAND, melds, dora_count, active_yaku, round_step
131 )
132 return True
134 if len(melds) >= 1 and round_step > 10 and sure_han >= 2 and self.enemy.is_dealer:
135 self._create_danger_reason(
136 EnemyDanger.THREAT_OPEN_HAND_UNKNOWN_COST, melds, dora_count, active_yaku, round_step
137 )
138 return True
140 # we are not sure how expensive this is, but let's be a little bit careful
141 if (round_step > 14 and len(melds) >= 1) or (round_step > 9 and len(melds) >= 2) or len(melds) >= 3:
142 self._create_danger_reason(
143 EnemyDanger.THREAT_OPEN_HAND_UNKNOWN_COST, melds, dora_count, active_yaku, round_step
144 )
145 return True
147 return False
149 def get_melds_han(self, tile_34) -> int:
150 melds_han = 0
152 for yaku_analyzer in self.threat_reason["active_yaku"]:
153 if not (tile_34 in yaku_analyzer.get_safe_tiles_34()) and not yaku_analyzer.is_absorbed(
154 self.threat_reason["active_yaku"], tile_34
155 ):
156 melds_han += yaku_analyzer.melds_han() * yaku_analyzer.get_tempai_probability_modifier()
158 return int(melds_han)
160 def get_assumed_hand_cost(self, tile_136, can_be_used_for_ryanmen=False) -> int:
161 """
162 How much the hand could cost
163 """
164 if self.enemy.in_riichi:
165 return self._calculate_assumed_hand_cost_for_riichi(tile_136, can_be_used_for_ryanmen)
166 return self._calculate_assumed_hand_cost(tile_136)
168 @property
169 def number_of_unverified_suji(self) -> int:
170 maximum_number_of_suji = 18
171 verified_suji = 0
172 suits = separate_tiles_by_suits(TilesConverter.to_34_array([x * 4 for x in self.enemy.all_safe_tiles]))
173 for suit in suits:
174 # indices started from 0
175 suji_indices = [[0, 3, 6], [1, 4, 7], [2, 5, 8]]
176 for suji in suji_indices:
177 if suit[suji[0]] and suit[suji[2]]:
178 verified_suji += 2
179 elif suit[suji[0]] or suit[suji[2]]:
180 verified_suji += 1
181 if suit[suji[1]]:
182 verified_suji += 1
183 elif suit[suji[1]]:
184 verified_suji += 2
185 result = maximum_number_of_suji - verified_suji
186 assert result >= 0, "number of unverified suji can't be less than 0"
187 return result
189 @property
190 def unverified_suji_coeff(self) -> int:
191 return self.calculate_suji_count_coeff(self.number_of_unverified_suji)
193 @staticmethod
194 def calculate_suji_count_coeff(unverified_suji_count: int) -> int:
195 return (TileDanger.SUJI_COUNT_BOUNDARY - unverified_suji_count) * TileDanger.SUJI_COUNT_MODIFIER
197 def _get_dora_scale_bonus(self, tile_136):
198 tile_34 = tile_136 // 4
199 scale_bonus = 0
201 dora_count = plus_dora(tile_136, self.table.dora_indicators, add_aka_dora=self.table.has_aka_dora)
203 if is_honor(tile_34):
204 closed_hand_34 = TilesConverter.to_34_array(self.main_player.closed_hand)
205 revealed_tiles = self.main_player.number_of_revealed_tiles(tile_34, closed_hand_34)
206 if revealed_tiles < 2:
207 scale_bonus += dora_count * 3
208 else:
209 scale_bonus += dora_count * 2
210 else:
211 scale_bonus += dora_count
213 return scale_bonus
215 def _calculate_assumed_hand_cost(self, tile_136) -> int:
216 tile_34 = tile_136 // 4
218 melds_han = self.get_melds_han(tile_34)
219 if melds_han == 0:
220 return 0
222 scale_index = melds_han
223 scale_index += self.threat_reason.get("dora_count", 0)
224 scale_index += self._get_dora_scale_bonus(tile_136)
226 if self.enemy.is_dealer:
227 scale = [1000, 2900, 5800, 12000, 12000, 18000, 18000, 24000, 24000, 24000, 36000, 36000, 48000]
228 else:
229 scale = [1000, 2000, 3900, 8000, 8000, 12000, 12000, 16000, 16000, 16000, 24000, 24000, 32000]
231 # add more danger for kan sets (basically it is additional hand cost because of fu)
232 for meld in self.enemy.melds:
233 if meld.type != Meld.KAN and meld.type != Meld.SHOUMINKAN:
234 continue
236 if meld.opened:
237 # enemy will get additional fu for opened honors or terminals kan
238 if is_honor(meld.tiles[0] // 4) or is_terminal(meld.tiles[0] // 4):
239 scale_index += 1
240 else:
241 # enemy will get additional fu for closed kan
242 scale_index += 1
244 if scale_index > len(scale) - 1:
245 scale_index = len(scale) - 1
246 elif scale_index == 0:
247 scale_index = 1
249 return scale[scale_index - 1]
251 def _calculate_assumed_hand_cost_for_riichi(self, tile_136, can_be_used_for_ryanmen) -> int:
252 scale_index = 0
254 if self.enemy.is_dealer:
255 scale = EnemyAnalyzer.RIICHI_DEALER_COST_SCALE
256 else:
257 scale = EnemyAnalyzer.RIICHI_COST_SCALE
259 riichi_stat = StatisticsCollector.collect_stat_for_enemy_riichi_hand_cost(
260 tile_136, self.enemy, self.main_player
261 )
263 # it wasn't early riichi, let's think that it could be more expensive
264 if 6 <= riichi_stat["riichi_called_on_step"] <= 11:
265 scale_index += 1
267 # more late riichi, probably means more expensive riichi
268 if riichi_stat["riichi_called_on_step"] >= 12:
269 scale_index += 2
271 if self.enemy.is_ippatsu:
272 scale_index += 1
274 # there are too many live dora tiles, let's increase hand cost
275 if riichi_stat["live_dora_tiles"] >= 4:
276 scale_index += 1
278 # if we are discarding dora we are obviously going to make enemy hand more expensive
279 scale_index += self._get_dora_scale_bonus(tile_136)
281 # plus two just because of riichi with kan
282 scale_index += riichi_stat["number_of_kan_in_enemy_hand"] * 2
283 # higher danger for doras
284 scale_index += riichi_stat["number_of_dora_in_enemy_kan_sets"]
285 # higher danger for yakuhai
286 scale_index += riichi_stat["number_of_yakuhai_enemy_kan_sets"]
288 # let's add more danger for all other opened kan sets on the table
289 scale_index += riichi_stat["number_of_other_player_kan_sets"]
291 # additional danger for tiles that could be used for tanyao
292 # 456
293 if riichi_stat["tile_category"] == "middle":
294 scale_index += 1
296 # additional danger for tiles that could be used for tanyao
297 # 23 or 78
298 if riichi_stat["tile_category"] == "edge" and can_be_used_for_ryanmen:
299 scale_index += 1
301 if scale_index > len(scale) - 1:
302 scale_index = len(scale) - 1
304 return scale[scale_index]
306 def _create_danger_reason(self, danger_reason, melds=None, dora_count=0, active_yaku=None, round_step=None):
307 new_danger_reason = copy(danger_reason)
308 new_danger_reason["melds"] = melds
309 new_danger_reason["dora_count"] = dora_count
310 new_danger_reason["active_yaku"] = active_yaku
311 new_danger_reason["round_step"] = round_step
313 self.threat_reason = new_danger_reason