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 

2from typing import List, Optional 

3 

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 

12 

13 

14class TileDangerHandler: 

15 player = None 

16 _analyzed_enemies: Optional[List[EnemyAnalyzer]] = None 

17 _threats_cache: Optional[List[EnemyAnalyzer]] = None 

18 

19 def __init__(self, player): 

20 self.player = player 

21 self._analyzed_enemies = [] 

22 self._threats_cache = [] 

23 

24 self.possible_forms_analyzer = PossibleFormsAnalyzer(player) 

25 

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) 

30 

31 safe_against_threat_34 = [] 

32 

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

35 

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

43 

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) 

51 

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 

61 

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 

71 

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 ) 

85 

86 if danger: 

87 self._update_discard_candidate( 

88 tile_34, 

89 discard_candidates, 

90 enemy_analyzer.enemy.seat, 

91 danger, 

92 ) 

93 

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 ) 

105 

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 ) 

122 

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 ) 

133 

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 ) 

143 

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 ) 

153 

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 ) 

161 

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 ) 

170 

171 dora_count = plus_dora( 

172 tile_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora 

173 ) 

174 

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 ) 

185 

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 ) 

196 

197 return discard_candidates 

198 

199 def calculate_danger_borders(self, discard_options, threatening_player, all_threatening_players): 

200 min_shanten = min([x.shanten for x in discard_options]) 

201 

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 

209 

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 ) 

216 

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 

223 

224 if discard_option.shanten == 0: 

225 hand_weighted_cost = self.player.ai.estimate_weighted_mean_hand_value(discard_option) 

226 

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 

237 

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 

241 

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 

306 

307 if discard_option.shanten == 1: 

308 tune = self.player.config.TUNE_DANGER_BORDER_1_SHANTEN_VALUE 

309 

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 

315 

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 

326 

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 

330 

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 

410 

411 if discard_option.shanten == 2: 

412 tune = self.player.config.TUNE_DANGER_BORDER_2_SHANTEN_VALUE 

413 

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] 

418 

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 

425 

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 ) 

432 

433 han += dora_count 

434 

435 hand_weighted_cost = scale[min(han, len(scale) - 1)] 

436 

437 discard_option.danger.weighted_cost = int(hand_weighted_cost) 

438 cost_ratio = (hand_weighted_cost / threatening_player_hand_cost) * 100 

439 

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 

471 

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) 

476 

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) 

480 

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) 

483 

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 

488 

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 

492 

493 result = [] 

494 for player in self.analyzed_enemies: 

495 if player.is_threatening: 

496 result.append(player) 

497 

498 return result 

499 

500 def erase_threats_cache(self): 

501 self._threats_cache = None 

502 

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 

509 

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) 

514 

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 

521 

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 

537 

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 

549 

550 return None 

551 

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 

567 

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 

580 

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] 

587 

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 

591 

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 

597 

598 return TileDanger.SUJI 

599 

600 return None 

601 

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) 

605 

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 

614 

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 

630 

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 

646 

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 

654 

655 if number_of_revealed_tiles == 3: 

656 danger = TileDanger.HONOR_THIRD 

657 

658 return danger 

659 

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 

667 

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) 

671 

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) 

679 

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] 

683 

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 ] 

702 

703 for is_suit in [is_pin, is_sou, is_man]: 

704 if not is_suit(tile_analyze_34): 

705 continue 

706 

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) 

712 

713 # +1 here to make it easier to read 

714 tile_analyze_simplified = simplify(tile_analyze_34) + 1 

715 

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 

722 

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 

729 

730 return False 

731 

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] 

735 

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

745 

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

749 

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 ] 

759 

760 for enemy_discard_34 in latest_discards_34: 

761 if not is_tiles_same_suit(enemy_discard_34, tile_analyze_34): 

762 continue 

763 

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 

767 

768 for matagi_pattern_config in matagi_patterns_config: 

769 if matagi_pattern_config["tile"] != enemy_discard_simplified: 

770 continue 

771 

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 

776 

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) 

782 

783 for danger in matagi_pattern_config["dangers"]: 

784 

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 

788 

789 return False 

790 

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] 

794 

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 

801 

802 # too early to make statements 

803 if len(discards_34) <= 5: 

804 return None 

805 

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 

810 

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 

815 

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] 

819 

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 ) 

859 

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 

865 

866 if not is_tiles_same_suit(enemy_discard_34, tile_analyze_34): 

867 continue 

868 

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 

875 

876 if tile_analyze_simplified in pattern_config["danger"]: 

877 return pattern_config["bonus"] 

878 

879 return None