Hide keyboard shortcuts

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 

2 

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 

16 

17 

18class EnemyAnalyzer: 

19 enemy = None 

20 threat_reason = None 

21 

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] 

24 

25 def __init__(self, player): 

26 self.enemy = player 

27 self.table = player.table 

28 

29 # is our bot 

30 self.main_player = self.table.player 

31 self.possible_forms_analyzer = PossibleFormsAnalyzer(self.main_player) 

32 

33 def serialize(self): 

34 return {"seat": self.enemy.seat, "threat_reason": self.threat_reason} 

35 

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 

42 

43 if not discards: 

44 return [] 

45 

46 discards_from_hand = [x for x in discards if not x.is_tsumogiri] 

47 if not discards_from_hand: 

48 return [] 

49 

50 last_from_hand = discards_from_hand[-1] 

51 index_of_last_from_hand = discards.index(last_from_hand) 

52 

53 return discards[: index_of_last_from_hand + 1] 

54 

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 

63 

64 if len(self.enemy.melds) == 4: 

65 return True 

66 

67 return False 

68 

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) 

75 

76 if self.enemy.in_riichi: 

77 self._create_danger_reason(EnemyDanger.THREAT_RIICHI, round_step=round_step) 

78 return True 

79 

80 melds = self.enemy.melds 

81 # we can't analyze closed hands for now 

82 if not melds: 

83 return False 

84 

85 active_yaku = [] 

86 sure_han = 0 

87 

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() 

92 

93 yaku_analyzers = [ 

94 ChinitsuAnalyzer(self.enemy), 

95 HonitsuAnalyzer(self.enemy), 

96 ToitoiAnalyzer(self.enemy), 

97 TanyaoAnalyzer(self.enemy), 

98 ] 

99 

100 for x in yaku_analyzers: 

101 if x.is_yaku_active(): 

102 active_yaku.append(x) 

103 

104 if not active_yaku: 

105 active_yaku.append(AtodzukeAnalyzer(self.enemy)) 

106 sure_han = 1 

107 

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 

115 

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 

121 

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 

127 

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 

133 

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 

139 

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 

146 

147 return False 

148 

149 def get_melds_han(self, tile_34) -> int: 

150 melds_han = 0 

151 

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() 

157 

158 return int(melds_han) 

159 

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) 

167 

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 

188 

189 @property 

190 def unverified_suji_coeff(self) -> int: 

191 return self.calculate_suji_count_coeff(self.number_of_unverified_suji) 

192 

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 

196 

197 def _get_dora_scale_bonus(self, tile_136): 

198 tile_34 = tile_136 // 4 

199 scale_bonus = 0 

200 

201 dora_count = plus_dora(tile_136, self.table.dora_indicators, add_aka_dora=self.table.has_aka_dora) 

202 

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 

212 

213 return scale_bonus 

214 

215 def _calculate_assumed_hand_cost(self, tile_136) -> int: 

216 tile_34 = tile_136 // 4 

217 

218 melds_han = self.get_melds_han(tile_34) 

219 if melds_han == 0: 

220 return 0 

221 

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) 

225 

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] 

230 

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 

235 

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 

243 

244 if scale_index > len(scale) - 1: 

245 scale_index = len(scale) - 1 

246 elif scale_index == 0: 

247 scale_index = 1 

248 

249 return scale[scale_index - 1] 

250 

251 def _calculate_assumed_hand_cost_for_riichi(self, tile_136, can_be_used_for_ryanmen) -> int: 

252 scale_index = 0 

253 

254 if self.enemy.is_dealer: 

255 scale = EnemyAnalyzer.RIICHI_DEALER_COST_SCALE 

256 else: 

257 scale = EnemyAnalyzer.RIICHI_COST_SCALE 

258 

259 riichi_stat = StatisticsCollector.collect_stat_for_enemy_riichi_hand_cost( 

260 tile_136, self.enemy, self.main_player 

261 ) 

262 

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 

266 

267 # more late riichi, probably means more expensive riichi 

268 if riichi_stat["riichi_called_on_step"] >= 12: 

269 scale_index += 2 

270 

271 if self.enemy.is_ippatsu: 

272 scale_index += 1 

273 

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 

277 

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) 

280 

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"] 

287 

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"] 

290 

291 # additional danger for tiles that could be used for tanyao 

292 # 456 

293 if riichi_stat["tile_category"] == "middle": 

294 scale_index += 1 

295 

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 

300 

301 if scale_index > len(scale) - 1: 

302 scale_index = len(scale) - 1 

303 

304 return scale[scale_index] 

305 

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 

312 

313 self.threat_reason = new_danger_reason