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 typing import List 

2 

3import utils.decisions_constants as log 

4from game.ai.discard import DiscardOption 

5from game.ai.helpers.kabe import Kabe 

6from mahjong.shanten import Shanten 

7from mahjong.tile import Tile, TilesConverter 

8from mahjong.utils import is_honor, is_pair, is_terminal, is_tile_strictly_isolated, plus_dora, simplify 

9from utils.decisions_logger import MeldPrint 

10 

11 

12class HandBuilder: 

13 player = None 

14 ai = None 

15 

16 def __init__(self, player, ai): 

17 self.player = player 

18 self.ai = ai 

19 

20 def discard_tile(self): 

21 selected_tile = self.choose_tile_to_discard() 

22 return self.process_discard_option(selected_tile) 

23 

24 def formal_riichi_conditions_for_discard(self, discard_option): 

25 remaining_tiles = self.player.table.count_of_remaining_tiles 

26 scores = self.player.scores 

27 

28 return all( 

29 [ 

30 # <= 0 because technically riichi from agari is possible 

31 discard_option.shanten <= 0, 

32 not self.player.in_riichi, 

33 not self.player.is_open_hand, 

34 # in some tests this value may be not initialized 

35 scores and scores >= 1000 or False, 

36 remaining_tiles and remaining_tiles > 4 or False, 

37 ] 

38 ) 

39 

40 def mark_tiles_riichi_decision(self, discard_options): 

41 threats = self.player.ai.defence.get_threatening_players() 

42 for discard_option in discard_options: 

43 if not self.formal_riichi_conditions_for_discard(discard_option): 

44 continue 

45 

46 if self.player.ai.riichi.should_call_riichi(discard_option, threats): 

47 discard_option.with_riichi = True 

48 

49 def choose_tile_to_discard(self, after_meld=False): 

50 """ 

51 Try to find best tile to discard, based on different evaluations 

52 """ 

53 self._assert_hand_correctness() 

54 

55 threatening_players = None 

56 

57 discard_options, min_shanten = self.find_discard_options() 

58 if min_shanten == Shanten.AGARI_STATE: 

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

60 

61 if not after_meld: 

62 self.mark_tiles_riichi_decision(discard_options) 

63 

64 one_shanten_ukeire2_calculated_beforehand = False 

65 if self.player.config.FEATURE_DEFENCE_ENABLED: 

66 # FIXME: this is hacky and takes too much time! refactor 

67 # we need to calculate ukeire2 beforehand for correct danger calculation 

68 if self.player.ai.defence.get_threatening_players() and min_shanten != 0: 

69 for discard_option in discard_options: 

70 if discard_option.shanten == 1: 

71 self.calculate_second_level_ukeire(discard_option, after_meld) 

72 one_shanten_ukeire2_calculated_beforehand = True 

73 

74 discard_options, threatening_players = self.player.ai.defence.mark_tiles_danger_for_threats(discard_options) 

75 

76 if threatening_players: 

77 self.player.logger.debug(log.DEFENCE_THREATENING_ENEMY, "Threats", context=threatening_players) 

78 

79 self.player.logger.debug(log.DISCARD_OPTIONS, "All discard candidates", discard_options) 

80 

81 tiles_we_can_discard = [x for x in discard_options if x.danger.is_danger_acceptable()] 

82 if not tiles_we_can_discard: 

83 # no tiles with acceptable danger - we go betaori 

84 return self._choose_safest_tile_or_skip_meld(discard_options, after_meld) 

85 

86 # our strategy can affect discard options 

87 if self.ai.open_hand_handler.current_strategy: 

88 tiles_we_can_discard = self.ai.open_hand_handler.current_strategy.determine_what_to_discard( 

89 tiles_we_can_discard, self.player.closed_hand, self.player.melds 

90 ) 

91 

92 had_to_be_discarded_tiles = [x for x in tiles_we_can_discard if x.had_to_be_discarded] 

93 if had_to_be_discarded_tiles: 

94 # we don't care about effectiveness of tiles that don't suit our strategy, 

95 # so just choose the safest one 

96 return self._choose_safest_tile(had_to_be_discarded_tiles) 

97 

98 # we check this after strategy checks to allow discarding safe 99 from tempai to get tanyao for example 

99 min_acceptable_shanten = min([x.shanten for x in tiles_we_can_discard]) 

100 assert min_acceptable_shanten >= min_shanten 

101 if min_acceptable_shanten > min_shanten: 

102 # all tiles with acceptable danger increase number of shanten - we just go betaori 

103 return self._choose_safest_tile_or_skip_meld(discard_options, after_meld) 

104 

105 # we should have some plan for push when we open our hand, otherwise - don't meld 

106 if threatening_players and after_meld and min_shanten > 0: 

107 if not self._find_acceptable_path_to_tempai(tiles_we_can_discard, min_shanten): 

108 return None 

109 

110 # by now we have the following: 

111 # - all discard candidates have acceptable danger 

112 # - all discard candidates allow us to proceed with our strategy 

113 tiles_we_can_discard = sorted(tiles_we_can_discard, key=lambda x: (x.shanten, -x.ukeire)) 

114 first_option = tiles_we_can_discard[0] 

115 assert first_option.shanten == min_shanten 

116 results_with_same_shanten = [x for x in tiles_we_can_discard if x.shanten == first_option.shanten] 

117 

118 if first_option.shanten == 0: 

119 return self._choose_best_discard_in_tempai(results_with_same_shanten, after_meld) 

120 

121 if first_option.shanten == 1: 

122 return self._choose_best_discard_with_1_shanten( 

123 results_with_same_shanten, after_meld, one_shanten_ukeire2_calculated_beforehand 

124 ) 

125 

126 if first_option.shanten == 2 or first_option.shanten == 3: 

127 return self._choose_best_discard_with_2_3_shanten(results_with_same_shanten, after_meld) 

128 

129 return self._choose_best_discard_with_4_or_more_shanten(results_with_same_shanten) 

130 

131 def process_discard_option(self, discard_option): 

132 self.player.logger.debug(log.DISCARD, context=discard_option) 

133 

134 self.player.in_tempai = discard_option.shanten == 0 

135 self.ai.waiting = discard_option.waiting 

136 self.ai.shanten = discard_option.shanten 

137 self.ai.ukeire = discard_option.ukeire 

138 self.ai.ukeire_second = discard_option.ukeire_second 

139 

140 return discard_option.tile_to_discard_136, discard_option.with_riichi 

141 

142 def calculate_shanten_and_decide_hand_structure(self, closed_hand_34): 

143 shanten_without_chiitoitsu = self.ai.calculate_shanten_or_get_from_cache(closed_hand_34, use_chiitoitsu=False) 

144 

145 if not self.player.is_open_hand: 

146 shanten_with_chiitoitsu = self.ai.calculate_shanten_or_get_from_cache(closed_hand_34, use_chiitoitsu=True) 

147 return self._decide_if_use_chiitoitsu(shanten_with_chiitoitsu, shanten_without_chiitoitsu) 

148 else: 

149 return shanten_without_chiitoitsu, False 

150 

151 def calculate_waits(self, closed_hand_34: List[int], all_tiles_34: List[int], use_chiitoitsu: bool = False): 

152 previous_shanten = self.ai.calculate_shanten_or_get_from_cache(closed_hand_34, use_chiitoitsu=use_chiitoitsu) 

153 

154 waiting = [] 

155 for tile_index in range(0, 34): 

156 # it is important to check that we don't have all 4 tiles in the all tiles 

157 # not in the closed hand 

158 # so, we will not count 1z as waiting when we have 111z meld 

159 if all_tiles_34[tile_index] == 4: 

160 continue 

161 

162 closed_hand_34[tile_index] += 1 

163 

164 skip_isolated_tile = True 

165 if closed_hand_34[tile_index] == 4: 

166 skip_isolated_tile = False 

167 if use_chiitoitsu and closed_hand_34[tile_index] == 3: 

168 skip_isolated_tile = False 

169 

170 # there is no need to check single isolated tile 

171 if skip_isolated_tile and is_tile_strictly_isolated(closed_hand_34, tile_index): 

172 closed_hand_34[tile_index] -= 1 

173 continue 

174 

175 new_shanten = self.ai.calculate_shanten_or_get_from_cache(closed_hand_34, use_chiitoitsu=use_chiitoitsu) 

176 

177 if new_shanten == previous_shanten - 1: 

178 waiting.append(tile_index) 

179 

180 closed_hand_34[tile_index] -= 1 

181 

182 return waiting, previous_shanten 

183 

184 def find_discard_options(self): 

185 """ 

186 :param tiles: array of tiles in 136 format 

187 :param closed_hand: array of tiles in 136 format 

188 :return: 

189 """ 

190 self._assert_hand_correctness() 

191 

192 tiles = self.player.tiles 

193 closed_hand = self.player.closed_hand 

194 

195 tiles_34 = TilesConverter.to_34_array(tiles) 

196 closed_tiles_34 = TilesConverter.to_34_array(closed_hand) 

197 is_agari = self.ai.agari.is_agari(tiles_34, self.player.meld_34_tiles) 

198 

199 # we decide beforehand if we need to consider chiitoitsu for all of our possible discards 

200 min_shanten, use_chiitoitsu = self.calculate_shanten_and_decide_hand_structure(closed_tiles_34) 

201 

202 results = [] 

203 tile_34_prev = None 

204 # we iterate in reverse order to naturally handle aka-doras, i.e. discard regular 5 if we have it 

205 for tile_136 in reversed(self.player.closed_hand): 

206 tile_34 = tile_136 // 4 

207 # already added 

208 if tile_34 == tile_34_prev: 

209 continue 

210 else: 

211 tile_34_prev = tile_34 

212 

213 closed_tiles_34[tile_34] -= 1 

214 waiting, shanten = self.calculate_waits(closed_tiles_34, tiles_34, use_chiitoitsu=use_chiitoitsu) 

215 assert shanten >= min_shanten 

216 closed_tiles_34[tile_34] += 1 

217 

218 if waiting: 

219 wait_to_ukeire = dict(zip(waiting, [self.count_tiles([x], closed_tiles_34) for x in waiting])) 

220 results.append( 

221 DiscardOption( 

222 player=self.player, 

223 shanten=shanten, 

224 tile_to_discard_136=tile_136, 

225 waiting=waiting, 

226 ukeire=sum(wait_to_ukeire.values()), 

227 wait_to_ukeire=wait_to_ukeire, 

228 ) 

229 ) 

230 

231 if is_agari: 

232 shanten = Shanten.AGARI_STATE 

233 else: 

234 shanten = min_shanten 

235 

236 return results, shanten 

237 

238 def count_tiles(self, waiting, tiles_34): 

239 n = 0 

240 for tile_34 in waiting: 

241 n += 4 - self.player.number_of_revealed_tiles(tile_34, tiles_34) 

242 return n 

243 

244 def divide_hand(self, tiles, waiting): 

245 tiles_copy = tiles[:] 

246 

247 for i in range(0, 4): 

248 if waiting * 4 + i not in tiles_copy: 

249 tiles_copy += [waiting * 4 + i] 

250 break 

251 

252 tiles_34 = TilesConverter.to_34_array(tiles_copy) 

253 

254 results = self.player.ai.hand_divider.divide_hand(tiles_34, self.player.melds) 

255 return results, tiles_34 

256 

257 def emulate_discard(self, discard_option): 

258 player_tiles_original = self.player.tiles[:] 

259 player_discards_original = self.player.discards[:] 

260 

261 tile_in_hand = discard_option.tile_to_discard_136 

262 

263 self.player.discards.append(Tile(tile_in_hand, False)) 

264 self.player.tiles.remove(tile_in_hand) 

265 

266 return player_tiles_original, player_discards_original 

267 

268 def restore_after_emulate_discard(self, tiles_original, discards_original): 

269 self.player.tiles = tiles_original 

270 self.player.discards = discards_original 

271 

272 def check_suji_and_kabe(self, tiles_34, waiting): 

273 # let's find suji-traps in our discard 

274 suji_tiles = self.player.ai.suji.find_suji([x.value for x in self.player.discards]) 

275 have_suji = waiting in suji_tiles 

276 

277 # let's find kabe 

278 kabe_tiles = self.player.ai.kabe.find_all_kabe(tiles_34) 

279 have_kabe = False 

280 for kabe in kabe_tiles: 

281 if waiting == kabe["tile"] and kabe["type"] == Kabe.STRONG_KABE: 

282 have_kabe = True 

283 break 

284 

285 return have_suji, have_kabe 

286 

287 def calculate_second_level_ukeire(self, discard_option, after_meld=False): 

288 self._assert_hand_correctness() 

289 

290 not_suitable_tiles = ( 

291 self.ai.open_hand_handler.current_strategy 

292 and self.ai.open_hand_handler.current_strategy.not_suitable_tiles 

293 or [] 

294 ) 

295 call_riichi = discard_option.with_riichi 

296 

297 # we are going to do manipulations that require player hand and discards to be updated 

298 # so we save original tiles and discards here and restore it at the end of the function 

299 player_tiles_original, player_discards_original = self.emulate_discard(discard_option) 

300 

301 sum_tiles = 0 

302 sum_cost = 0 

303 average_costs = [] 

304 for wait_34 in discard_option.waiting: 

305 if self.player.is_open_hand and wait_34 in not_suitable_tiles: 

306 continue 

307 

308 closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) 

309 live_tiles = 4 - self.player.number_of_revealed_tiles(wait_34, closed_hand_34) 

310 

311 if live_tiles == 0: 

312 continue 

313 

314 wait_136 = self._find_live_tile(wait_34) 

315 assert wait_136 is not None 

316 self.player.tiles.append(wait_136) 

317 

318 results, shanten = self.find_discard_options() 

319 results = [x for x in results if x.shanten == discard_option.shanten - 1] 

320 

321 # let's take best ukeire here 

322 if results: 

323 result_has_atodzuke = False 

324 if self.player.is_open_hand: 

325 best_one = results[0] 

326 best_ukeire = 0 

327 for result in results: 

328 has_atodzuke = False 

329 ukeire = 0 

330 for wait_34 in result.waiting: 

331 if wait_34 in not_suitable_tiles: 

332 has_atodzuke = True 

333 else: 

334 ukeire += result.wait_to_ukeire[wait_34] 

335 

336 # let's consider atodzuke waits to be worse than non-atodzuke ones 

337 if has_atodzuke: 

338 ukeire /= 2 

339 

340 # FIXME consider sorting by cost_x_ukeire as well 

341 if (ukeire > best_ukeire) or (ukeire >= best_ukeire and not has_atodzuke): 

342 best_ukeire = ukeire 

343 best_one = result 

344 result_has_atodzuke = has_atodzuke 

345 else: 

346 if shanten == 0: 

347 # FIXME save cost_x_ukeire to not calculate it twice 

348 best_one = sorted( 

349 results, 

350 key=lambda x: (-x.ukeire, -self._estimate_cost_x_ukeire(x, call_riichi=call_riichi)[0]), 

351 )[0] 

352 else: 

353 best_one = sorted(results, key=lambda x: -x.ukeire)[0] 

354 best_ukeire = best_one.ukeire 

355 

356 sum_tiles += best_ukeire * live_tiles 

357 

358 # if we are going to have a tempai (on our second level) - let's also count its cost 

359 if shanten == 0: 

360 # temporary update players hand and discards for calculations 

361 next_tile_in_hand = best_one.tile_to_discard_136 

362 tile_for_discard = Tile(next_tile_in_hand, False) 

363 self.player.tiles.remove(next_tile_in_hand) 

364 self.player.discards.append(tile_for_discard) 

365 

366 cost_x_ukeire, _ = self._estimate_cost_x_ukeire(best_one, call_riichi=call_riichi) 

367 if best_ukeire != 0: 

368 average_costs.append(cost_x_ukeire / best_ukeire) 

369 # we reduce tile valuation for atodzuke 

370 if result_has_atodzuke: 

371 cost_x_ukeire /= 2 

372 sum_cost += cost_x_ukeire 

373 

374 # restore original players hand and discard state 

375 self.player.tiles.append(next_tile_in_hand) 

376 self.player.discards.remove(tile_for_discard) 

377 

378 self.player.tiles.remove(wait_136) 

379 

380 discard_option.ukeire_second = sum_tiles 

381 if discard_option.shanten == 1: 

382 if discard_option.ukeire != 0: 

383 discard_option.average_second_level_waits = round(sum_tiles / discard_option.ukeire, 2) 

384 

385 discard_option.second_level_cost = sum_cost 

386 if not average_costs: 

387 discard_option.average_second_level_cost = 0 

388 else: 

389 discard_option.average_second_level_cost = int(sum(average_costs) / len(average_costs)) 

390 

391 # restore original state of player hand and discards 

392 self.restore_after_emulate_discard(player_tiles_original, player_discards_original) 

393 

394 def _decide_if_use_chiitoitsu(self, shanten_with_chiitoitsu, shanten_without_chiitoitsu): 

395 # if it's late get 1-shanten for chiitoitsu instead of 2-shanten for another hand 

396 if len(self.player.discards) <= 10: 

397 border_shanten_without_chiitoitsu = 3 

398 else: 

399 border_shanten_without_chiitoitsu = 2 

400 

401 if (shanten_with_chiitoitsu == 0 and shanten_without_chiitoitsu >= 1) or ( 

402 shanten_with_chiitoitsu == 1 and shanten_without_chiitoitsu >= border_shanten_without_chiitoitsu 

403 ): 

404 shanten = shanten_with_chiitoitsu 

405 use_chiitoitsu = True 

406 else: 

407 shanten = shanten_without_chiitoitsu 

408 use_chiitoitsu = False 

409 

410 return shanten, use_chiitoitsu 

411 

412 @staticmethod 

413 def _default_sorting_rule(x): 

414 return ( 

415 x.shanten, 

416 -x.ukeire, 

417 x.valuation, 

418 ) 

419 

420 @staticmethod 

421 def _sorting_rule_for_1_shanten(x): 

422 return (-x.second_level_cost,) + HandBuilder._sorting_rule_for_1_2_3_shanten_simple(x) 

423 

424 @staticmethod 

425 def _sorting_rule_for_1_2_3_shanten_simple(x): 

426 return ( 

427 -x.ukeire_second, 

428 -x.ukeire, 

429 x.valuation, 

430 ) 

431 

432 @staticmethod 

433 def _sorting_rule_for_2_3_shanten_with_isolated(x, closed_hand_34): 

434 return ( 

435 -x.ukeire_second, 

436 -x.ukeire, 

437 -is_tile_strictly_isolated(closed_hand_34, x.tile_to_discard_34), 

438 x.valuation, 

439 ) 

440 

441 @staticmethod 

442 def _sorting_rule_for_4_or_more_shanten(x): 

443 return ( 

444 -x.ukeire, 

445 x.valuation, 

446 ) 

447 

448 @staticmethod 

449 def _sorting_rule_for_betaori(x): 

450 return (x.danger.get_weighted_danger(),) + HandBuilder._default_sorting_rule(x) 

451 

452 def _choose_safest_tile_or_skip_meld(self, discard_options, after_meld): 

453 if after_meld: 

454 return None 

455 

456 # we can't discard effective tile from the hand, let's fold 

457 self.player.logger.debug(log.DISCARD_SAFE_TILE, "There are only dangerous tiles. Discard safest tile.") 

458 return sorted(discard_options, key=self._sorting_rule_for_betaori)[0] 

459 

460 def _choose_safest_tile(self, discard_options): 

461 return self._choose_safest_tile_or_skip_meld(discard_options, after_meld=False) 

462 

463 def _choose_best_tile(self, discard_options): 

464 self.player.logger.debug(log.DISCARD_OPTIONS, "All discard candidates", discard_options) 

465 

466 return discard_options[0] 

467 

468 def _choose_best_tile_considering_threats(self, discard_options, sorting_rule): 

469 assert discard_options 

470 

471 threats_present = [x for x in discard_options if x.danger.get_max_danger() != 0] 

472 if threats_present: 

473 # try to discard safest tile for calculated ukeire border 

474 candidate = sorted(discard_options, key=sorting_rule)[0] 

475 ukeire_border = max( 

476 [ 

477 round((candidate.ukeire / 100) * DiscardOption.UKEIRE_DANGER_FILTER_PERCENTAGE), 

478 DiscardOption.MIN_UKEIRE_DANGER_BORDER, 

479 ] 

480 ) 

481 

482 discard_options_within_borders = sorted( 

483 [x for x in discard_options if x.ukeire >= x.ukeire - ukeire_border], 

484 key=lambda x: (x.danger.get_weighted_danger(),) + sorting_rule(x), 

485 ) 

486 else: 

487 discard_options_within_borders = discard_options 

488 

489 return self._choose_best_tile(discard_options_within_borders) 

490 

491 def _choose_first_option_or_safe_tiles(self, chosen_candidates, all_discard_options, after_meld, sorting_lambda): 

492 # it looks like everything is fine 

493 if len(chosen_candidates): 

494 return self._choose_best_tile_considering_threats(chosen_candidates, sorting_lambda) 

495 

496 return self._choose_safest_tile_or_skip_meld(all_discard_options, after_meld) 

497 

498 def _choose_best_tanki_wait(self, discard_desc): 

499 discard_desc = sorted(discard_desc, key=lambda k: (k["hand_cost"], -k["weighted_danger"]), reverse=True) 

500 discard_desc = [x for x in discard_desc if x["hand_cost"] != 0] 

501 

502 # we are guaranteed to have at least one wait with cost by caller logic 

503 assert len(discard_desc) > 0 

504 

505 if len(discard_desc) == 1: 

506 return discard_desc[0]["discard_option"] 

507 

508 non_furiten_waits = [x for x in discard_desc if not x["is_furiten"]] 

509 num_non_furiten_waits = len(non_furiten_waits) 

510 if num_non_furiten_waits == 1: 

511 return non_furiten_waits[0]["discard_option"] 

512 elif num_non_furiten_waits > 1: 

513 discard_desc = non_furiten_waits 

514 

515 best_discard_desc = [x for x in discard_desc if x["hand_cost"] == discard_desc[0]["hand_cost"]] 

516 

517 # first of all we choose the most expensive wait 

518 if len(best_discard_desc) == 1: 

519 return best_discard_desc[0]["discard_option"] 

520 

521 best_ukeire = best_discard_desc[0]["discard_option"].ukeire 

522 diff = best_ukeire - best_discard_desc[1]["discard_option"].ukeire 

523 # if both tanki waits have the same ukeire 

524 if diff == 0: 

525 # case when we have 2 or 3 tiles to wait for 

526 if best_ukeire == 2 or best_ukeire == 3: 

527 best_discard_desc = sorted( 

528 best_discard_desc, 

529 key=lambda k: (TankiWait.tanki_wait_same_ukeire_2_3_prio[k["tanki_type"]], -k["weighted_danger"]), 

530 reverse=True, 

531 ) 

532 return best_discard_desc[0]["discard_option"] 

533 

534 # case when we have 1 tile to wait for 

535 if best_ukeire == 1: 

536 best_discard_desc = sorted( 

537 best_discard_desc, 

538 key=lambda k: (TankiWait.tanki_wait_same_ukeire_1_prio[k["tanki_type"]], -k["weighted_danger"]), 

539 reverse=True, 

540 ) 

541 return best_discard_desc[0]["discard_option"] 

542 

543 # should never reach here 

544 raise AssertionError("Can't chose tanki wait") 

545 

546 # if one tanki wait has 1 more tile to wait than the other we only choose the latter one if it is 

547 # a wind or alike and the first one is not 

548 if diff == 1: 

549 prio_0 = TankiWait.tanki_wait_diff_ukeire_prio[best_discard_desc[0]["tanki_type"]] 

550 prio_1 = TankiWait.tanki_wait_diff_ukeire_prio[best_discard_desc[1]["tanki_type"]] 

551 if prio_1 > prio_0: 

552 return best_discard_desc[1]["discard_option"] 

553 

554 return best_discard_desc[0]["discard_option"] 

555 

556 if diff > 1: 

557 return best_discard_desc[0]["discard_option"] 

558 

559 # if everything is the same we just choose the first one 

560 return best_discard_desc[0]["discard_option"] 

561 

562 def _is_waiting_furiten(self, tile_34): 

563 discarded_tiles = [x.value // 4 for x in self.player.discards] 

564 return tile_34 in discarded_tiles 

565 

566 def _is_discard_option_furiten(self, discard_option): 

567 is_furiten = False 

568 

569 for waiting in discard_option.waiting: 

570 is_furiten = is_furiten or self._is_waiting_furiten(waiting) 

571 

572 return is_furiten 

573 

574 def _choose_best_discard_in_tempai(self, discard_options, after_meld): 

575 discard_desc = [] 

576 

577 closed_tiles_34 = TilesConverter.to_34_array(self.player.closed_hand) 

578 

579 for discard_option in discard_options: 

580 call_riichi = discard_option.with_riichi 

581 tiles_original, discard_original = self.emulate_discard(discard_option) 

582 

583 is_furiten = self._is_discard_option_furiten(discard_option) 

584 

585 if len(discard_option.waiting) == 1: 

586 waiting = discard_option.waiting[0] 

587 

588 cost_x_ukeire, hand_cost = self._estimate_cost_x_ukeire(discard_option, call_riichi) 

589 

590 # let's check if this is a tanki wait 

591 results, tiles_34 = self.divide_hand(self.player.tiles, waiting) 

592 result = results[0] 

593 

594 tanki_type = None 

595 

596 is_tanki = False 

597 for hand_set in result: 

598 if waiting not in hand_set: 

599 continue 

600 

601 if is_pair(hand_set): 

602 is_tanki = True 

603 

604 if is_honor(waiting): 

605 # TODO: differentiate between self honor and honor for all players 

606 if waiting in self.player.valued_honors: 

607 tanki_type = TankiWait.TANKI_WAIT_ALL_YAKUHAI 

608 else: 

609 tanki_type = TankiWait.TANKI_WAIT_NON_YAKUHAI 

610 break 

611 

612 simplified_waiting = simplify(waiting) 

613 have_suji, have_kabe = self.check_suji_and_kabe(closed_tiles_34, waiting) 

614 

615 # TODO: not sure about suji/kabe priority, so we keep them same for now 

616 if 3 <= simplified_waiting <= 5: 

617 if have_suji or have_kabe: 

618 tanki_type = TankiWait.TANKI_WAIT_456_KABE 

619 else: 

620 tanki_type = TankiWait.TANKI_WAIT_456_RAW 

621 elif 2 <= simplified_waiting <= 6: 

622 if have_suji or have_kabe: 

623 tanki_type = TankiWait.TANKI_WAIT_37_KABE 

624 else: 

625 tanki_type = TankiWait.TANKI_WAIT_37_RAW 

626 elif 1 <= simplified_waiting <= 7: 

627 if have_suji or have_kabe: 

628 tanki_type = TankiWait.TANKI_WAIT_28_KABE 

629 else: 

630 tanki_type = TankiWait.TANKI_WAIT_28_RAW 

631 else: 

632 if have_suji or have_kabe: 

633 tanki_type = TankiWait.TANKI_WAIT_69_KABE 

634 else: 

635 tanki_type = TankiWait.TANKI_WAIT_69_RAW 

636 break 

637 

638 tempai_descriptor = { 

639 "discard_option": discard_option, 

640 "hand_cost": hand_cost, 

641 "cost_x_ukeire": cost_x_ukeire, 

642 "is_furiten": is_furiten, 

643 "is_tanki": is_tanki, 

644 "tanki_type": tanki_type, 

645 "max_danger": discard_option.danger.get_max_danger(), 

646 "sum_danger": discard_option.danger.get_sum_danger(), 

647 "weighted_danger": discard_option.danger.get_weighted_danger(), 

648 } 

649 discard_desc.append(tempai_descriptor) 

650 else: 

651 cost_x_ukeire, _ = self._estimate_cost_x_ukeire(discard_option, call_riichi) 

652 

653 tempai_descriptor = { 

654 "discard_option": discard_option, 

655 "hand_cost": None, 

656 "cost_x_ukeire": cost_x_ukeire, 

657 "is_furiten": is_furiten, 

658 "is_tanki": False, 

659 "tanki_type": None, 

660 "max_danger": discard_option.danger.get_max_danger(), 

661 "sum_danger": discard_option.danger.get_sum_danger(), 

662 "weighted_danger": discard_option.danger.get_weighted_danger(), 

663 } 

664 discard_desc.append(tempai_descriptor) 

665 

666 # save descriptor to discard option for future users 

667 discard_option.tempai_descriptor = tempai_descriptor 

668 

669 # reverse all temporary tile tweaks 

670 self.restore_after_emulate_discard(tiles_original, discard_original) 

671 

672 discard_desc = sorted(discard_desc, key=lambda k: (-k["cost_x_ukeire"], k["is_furiten"], k["weighted_danger"])) 

673 

674 # if we don't have any good options, e.g. all our possible waits are karaten 

675 if discard_desc[0]["cost_x_ukeire"] == 0: 

676 # we still choose between options that give us tempai, because we may be going to formal tempai 

677 # with no hand cost 

678 return self._choose_safest_tile(discard_options) 

679 

680 num_tanki_waits = len([x for x in discard_desc if x["is_tanki"]]) 

681 

682 # what if all our waits are tanki waits? we need a special handling for that case 

683 if num_tanki_waits == len(discard_options): 

684 return self._choose_best_tanki_wait(discard_desc) 

685 

686 best_discard_desc = [x for x in discard_desc if x["cost_x_ukeire"] == discard_desc[0]["cost_x_ukeire"]] 

687 best_discard_desc = sorted(best_discard_desc, key=lambda k: (k["is_furiten"], k["weighted_danger"])) 

688 

689 # if we have several options that give us similar wait 

690 return best_discard_desc[0]["discard_option"] 

691 

692 # this method is used when there are no threats but we are deciding if we should keep safe tile or useful tile 

693 def _simplified_danger_valuation(self, discard_option): 

694 tile_34 = discard_option.tile_to_discard_34 

695 tile_136 = discard_option.tile_to_discard_136 

696 number_of_revealed_tiles = self.player.number_of_revealed_tiles( 

697 tile_34, TilesConverter.to_34_array(self.player.closed_hand) 

698 ) 

699 if is_honor(tile_34): 

700 if not self.player.table.is_common_yakuhai(tile_34): 

701 if number_of_revealed_tiles == 4: 

702 simple_danger = 0 

703 elif number_of_revealed_tiles == 3: 

704 simple_danger = 10 

705 elif number_of_revealed_tiles == 2: 

706 simple_danger = 20 

707 else: 

708 simple_danger = 30 

709 else: 

710 if number_of_revealed_tiles == 4: 

711 simple_danger = 0 

712 elif number_of_revealed_tiles == 3: 

713 simple_danger = 11 

714 elif number_of_revealed_tiles == 2: 

715 simple_danger = 21 

716 else: 

717 simple_danger = 32 

718 elif is_terminal(tile_34): 

719 simple_danger = 100 

720 elif simplify(tile_34) < 2 or simplify(tile_34) > 6: 

721 # 2, 3 or 7, 8 

722 simple_danger = 200 

723 else: 

724 # 4, 5, 6 

725 simple_danger = 300 

726 

727 if simple_danger != 0: 

728 simple_danger += plus_dora( 

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

730 ) 

731 

732 return simple_danger 

733 

734 def _sort_1_shanten_discard_options_no_threats(self, discard_options, best_ukeire): 

735 if self.player.round_step < 5 or best_ukeire <= 12: 

736 return self._choose_best_tile(sorted(discard_options, key=self._sorting_rule_for_1_shanten)) 

737 elif self.player.round_step < 13: 

738 # discard more dangerous tiles beforehand 

739 self.player.logger.debug( 

740 log.DISCARD_OPTIONS, 

741 "There are no threats yet, better discard useless dangerous tile beforehand", 

742 discard_options, 

743 ) 

744 danger_sign = -1 

745 else: 

746 # late round - discard safer tiles first 

747 self.player.logger.debug( 

748 log.DISCARD_OPTIONS, 

749 "There are no visible threats, but it's late, better keep useless dangerous tiles", 

750 discard_options, 

751 ) 

752 danger_sign = 1 

753 

754 return self._choose_best_tile( 

755 sorted( 

756 discard_options, 

757 key=lambda x: ( 

758 -x.second_level_cost, 

759 -x.ukeire_second, 

760 -x.ukeire, 

761 danger_sign * self._simplified_danger_valuation(x), 

762 ), 

763 ), 

764 ) 

765 

766 def _choose_best_discard_with_1_shanten( 

767 self, discard_options, after_meld, one_shanten_ukeire2_calculated_beforehand 

768 ): 

769 discard_options = sorted(discard_options, key=lambda x: (x.shanten, -x.ukeire)) 

770 first_option = discard_options[0] 

771 

772 # first we filter by ukeire 

773 ukeire_borders = self._choose_ukeire_borders( 

774 first_option, DiscardOption.UKEIRE_FIRST_FILTER_PERCENTAGE, "ukeire" 

775 ) 

776 possible_options = self._filter_list_by_ukeire_borders(discard_options, first_option.ukeire, ukeire_borders) 

777 

778 # FIXME: hack, sometimes we have already calculated it 

779 if not one_shanten_ukeire2_calculated_beforehand: 

780 for x in possible_options: 

781 self.calculate_second_level_ukeire(x, after_meld) 

782 

783 # then we filter by ukeire2 

784 possible_options = sorted(possible_options, key=self._sorting_rule_for_1_2_3_shanten_simple) 

785 possible_options = self._filter_list_by_percentage( 

786 possible_options, "ukeire_second", DiscardOption.UKEIRE_SECOND_FILTER_PERCENTAGE 

787 ) 

788 

789 threats_present = [x for x in discard_options if x.danger.get_max_danger() != 0] 

790 if threats_present: 

791 return self._choose_best_tile_considering_threats( 

792 sorted(possible_options, key=self._sorting_rule_for_1_shanten), 

793 self._sorting_rule_for_1_shanten, 

794 ) 

795 else: 

796 # if there are no theats we try to either keep or discard potentially dangerous tiles depending on the round 

797 return self._sort_1_shanten_discard_options_no_threats(possible_options, first_option.ukeire) 

798 

799 def _choose_best_discard_with_2_3_shanten(self, discard_options, after_meld): 

800 discard_options = sorted(discard_options, key=lambda x: (x.shanten, -x.ukeire)) 

801 first_option = discard_options[0] 

802 closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) 

803 

804 # first we filter by ukeire 

805 ukeire_borders = self._choose_ukeire_borders( 

806 first_option, DiscardOption.UKEIRE_FIRST_FILTER_PERCENTAGE, "ukeire" 

807 ) 

808 possible_options = self._filter_list_by_ukeire_borders(discard_options, first_option.ukeire, ukeire_borders) 

809 

810 for x in possible_options: 

811 self.calculate_second_level_ukeire(x, after_meld) 

812 

813 # then we filter by ukeire 2 

814 possible_options = sorted( 

815 possible_options, key=lambda x: self._sorting_rule_for_2_3_shanten_with_isolated(x, closed_hand_34) 

816 ) 

817 possible_options = self._filter_list_by_percentage( 

818 possible_options, "ukeire_second", DiscardOption.UKEIRE_SECOND_FILTER_PERCENTAGE 

819 ) 

820 

821 possible_options = self._try_keep_doras( 

822 possible_options, "ukeire_second", DiscardOption.UKEIRE_SECOND_FILTER_PERCENTAGE 

823 ) 

824 assert possible_options 

825 

826 self.player.logger.debug(log.DISCARD_OPTIONS, "Candidates after filtering by ukeire2", context=possible_options) 

827 

828 return self._choose_best_tile_considering_threats( 

829 sorted(possible_options, key=lambda x: self._sorting_rule_for_2_3_shanten_with_isolated(x, closed_hand_34)), 

830 self._sorting_rule_for_1_2_3_shanten_simple, 

831 ) 

832 

833 def _choose_best_discard_with_4_or_more_shanten(self, discard_options): 

834 discard_options = sorted(discard_options, key=lambda x: (x.shanten, -x.ukeire)) 

835 first_option = discard_options[0] 

836 

837 # we filter by ukeire 

838 ukeire_borders = self._choose_ukeire_borders( 

839 first_option, DiscardOption.UKEIRE_FIRST_FILTER_PERCENTAGE, "ukeire" 

840 ) 

841 possible_options = self._filter_list_by_ukeire_borders(discard_options, first_option.ukeire, ukeire_borders) 

842 

843 possible_options = sorted(possible_options, key=self._sorting_rule_for_4_or_more_shanten) 

844 

845 possible_options = self._try_keep_doras( 

846 possible_options, "ukeire", DiscardOption.UKEIRE_FIRST_FILTER_PERCENTAGE 

847 ) 

848 assert possible_options 

849 

850 closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) 

851 isolated_tiles = [ 

852 x for x in possible_options if is_tile_strictly_isolated(closed_hand_34, x.tile_to_discard_34) 

853 ] 

854 # isolated tiles should be discarded first 

855 if isolated_tiles: 

856 possible_options = isolated_tiles 

857 

858 # let's sort tiles by value and let's choose less valuable tile to discard 

859 return self._choose_best_tile_considering_threats( 

860 sorted(possible_options, key=self._sorting_rule_for_4_or_more_shanten), 

861 self._sorting_rule_for_4_or_more_shanten, 

862 ) 

863 

864 def _try_keep_doras(self, discard_options, ukeire_field, filter_percentage): 

865 tiles_without_dora = [x for x in discard_options if x.count_of_dora == 0] 

866 # we have only dora candidates to discard 

867 if not tiles_without_dora: 

868 min_dora = min([x.count_of_dora for x in discard_options]) 

869 min_dora_list = [x for x in discard_options if x.count_of_dora == min_dora] 

870 possible_options = min_dora_list 

871 else: 

872 # filter again - this time only tiles without dora 

873 possible_options = self._filter_list_by_percentage(tiles_without_dora, ukeire_field, filter_percentage) 

874 

875 return possible_options 

876 

877 def _find_acceptable_path_to_tempai(self, acceptable_discard_options, shanten): 

878 # this might be a bit conservative, because in tempai danger borders will be higher, but since 

879 # we expect that we need to discard a few more dangerous tiles before we get tempai, if we push 

880 # we can use danger borders for our current shanten number and it all compensates 

881 acceptable_discard_options_with_same_shanten = [x for x in acceptable_discard_options if x.shanten == shanten] 

882 # +1 because we need to discard one more tile to get to lower shanten number one more time 

883 # so if we want to meld and get 1-shanten we should have at least two tiles in hand we can discard 

884 # that don't increase shanten number 

885 if len(acceptable_discard_options_with_same_shanten) < shanten + 1: 

886 # there is no acceptable way for tempai even in theory 

887 return False 

888 

889 @staticmethod 

890 def _filter_list_by_ukeire_borders(discard_options, ukeire, ukeire_borders): 

891 filteted = [] 

892 

893 for discard_option in discard_options: 

894 # let's choose tiles that are close to the max ukeire tile 

895 if discard_option.ukeire >= ukeire - ukeire_borders: 

896 filteted.append(discard_option) 

897 

898 return filteted 

899 

900 @staticmethod 

901 def _filter_list_by_percentage(items, attribute, percentage): 

902 filtered_options = [] 

903 first_option = items[0] 

904 ukeire_borders = round((getattr(first_option, attribute) / 100) * percentage) 

905 for x in items: 

906 if getattr(x, attribute) >= getattr(first_option, attribute) - ukeire_borders: 

907 filtered_options.append(x) 

908 return filtered_options 

909 

910 @staticmethod 

911 def _choose_ukeire_borders(first_option, border_percentage, border_field): 

912 ukeire_borders = round((getattr(first_option, border_field) / 100) * border_percentage) 

913 

914 if first_option.shanten == 0 and ukeire_borders < DiscardOption.MIN_UKEIRE_TEMPAI_BORDER: 

915 ukeire_borders = DiscardOption.MIN_UKEIRE_TEMPAI_BORDER 

916 

917 if first_option.shanten == 1 and ukeire_borders < DiscardOption.MIN_UKEIRE_SHANTEN_1_BORDER: 

918 ukeire_borders = DiscardOption.MIN_UKEIRE_SHANTEN_1_BORDER 

919 

920 if first_option.shanten >= 2 and ukeire_borders < DiscardOption.MIN_UKEIRE_SHANTEN_2_BORDER: 

921 ukeire_borders = DiscardOption.MIN_UKEIRE_SHANTEN_2_BORDER 

922 

923 return ukeire_borders 

924 

925 def _estimate_cost_x_ukeire(self, discard_option, call_riichi): 

926 cost_x_ukeire_tsumo = 0 

927 cost_x_ukeire_ron = 0 

928 hand_cost_tsumo = 0 

929 hand_cost_ron = 0 

930 

931 is_furiten = self._is_discard_option_furiten(discard_option) 

932 

933 for waiting in discard_option.waiting: 

934 hand_value = self.player.ai.estimate_hand_value_or_get_from_cache( 

935 waiting, call_riichi=call_riichi, is_tsumo=True 

936 ) 

937 if hand_value.error is None: 

938 hand_cost_tsumo = hand_value.cost["main"] + 2 * hand_value.cost["additional"] 

939 cost_x_ukeire_tsumo += hand_cost_tsumo * discard_option.wait_to_ukeire[waiting] 

940 

941 if not is_furiten: 

942 hand_value = self.player.ai.estimate_hand_value_or_get_from_cache( 

943 waiting, call_riichi=call_riichi, is_tsumo=False 

944 ) 

945 if hand_value.error is None: 

946 hand_cost_ron = hand_value.cost["main"] 

947 cost_x_ukeire_ron += hand_cost_ron * discard_option.wait_to_ukeire[waiting] 

948 

949 # these are abstract numbers used to compare different waits 

950 # some don't have yaku, some furiten, etc. 

951 # so we use an abstract formula of 1 tsumo cost + 3 ron costs 

952 cost_x_ukeire = (cost_x_ukeire_tsumo + 3 * cost_x_ukeire_ron) // 4 

953 

954 if len(discard_option.waiting) == 1: 

955 hand_cost = (hand_cost_tsumo + 3 * hand_cost_ron) // 4 

956 else: 

957 hand_cost = None 

958 

959 return cost_x_ukeire, hand_cost 

960 

961 def _find_live_tile(self, tile_34): 

962 for i in range(0, 4): 

963 tile = tile_34 * 4 + i 

964 if not (tile in self.player.closed_hand) and not (tile in self.player.meld_tiles): 

965 return tile 

966 

967 return None 

968 

969 def _assert_hand_correctness(self): 

970 # we must always have correct hand to discard from, e.g. we cannot discard when we have 13 tiles 

971 num_kans = len([x for x in self.player.melds if x.type == MeldPrint.KAN or x.type == MeldPrint.SHOUMINKAN]) 

972 total_tiles = len(self.player.tiles) 

973 allowed_tiles = 14 + num_kans 

974 assert total_tiles == allowed_tiles, f"{total_tiles} != {allowed_tiles}" 

975 assert ( 

976 len(self.player.closed_hand) == 2 

977 or len(self.player.closed_hand) == 5 

978 or len(self.player.closed_hand) == 8 

979 or len(self.player.closed_hand) == 11 

980 or len(self.player.closed_hand) == 14 

981 ) 

982 

983 

984class TankiWait: 

985 TANKI_WAIT_NON_YAKUHAI = 1 

986 TANKI_WAIT_SELF_YAKUHAI = 2 

987 TANKI_WAIT_ALL_YAKUHAI = 3 

988 TANKI_WAIT_69_KABE = 4 

989 TANKI_WAIT_69_SUJI = 5 

990 TANKI_WAIT_69_RAW = 6 

991 TANKI_WAIT_28_KABE = 7 

992 TANKI_WAIT_28_SUJI = 8 

993 TANKI_WAIT_28_RAW = 9 

994 TANKI_WAIT_37_KABE = 10 

995 TANKI_WAIT_37_SUJI = 11 

996 TANKI_WAIT_37_RAW = 12 

997 TANKI_WAIT_456_KABE = 13 

998 TANKI_WAIT_456_SUJI = 14 

999 TANKI_WAIT_456_RAW = 15 

1000 

1001 tanki_wait_same_ukeire_2_3_prio = { 

1002 TANKI_WAIT_NON_YAKUHAI: 15, 

1003 TANKI_WAIT_69_KABE: 14, 

1004 TANKI_WAIT_69_SUJI: 14, 

1005 TANKI_WAIT_SELF_YAKUHAI: 13, 

1006 TANKI_WAIT_ALL_YAKUHAI: 12, 

1007 TANKI_WAIT_28_KABE: 11, 

1008 TANKI_WAIT_28_SUJI: 11, 

1009 TANKI_WAIT_37_KABE: 10, 

1010 TANKI_WAIT_37_SUJI: 10, 

1011 TANKI_WAIT_69_RAW: 9, 

1012 TANKI_WAIT_456_KABE: 8, 

1013 TANKI_WAIT_456_SUJI: 8, 

1014 TANKI_WAIT_28_RAW: 7, 

1015 TANKI_WAIT_456_RAW: 6, 

1016 TANKI_WAIT_37_RAW: 5, 

1017 } 

1018 

1019 tanki_wait_same_ukeire_1_prio = { 

1020 TANKI_WAIT_NON_YAKUHAI: 15, 

1021 TANKI_WAIT_SELF_YAKUHAI: 14, 

1022 TANKI_WAIT_ALL_YAKUHAI: 13, 

1023 TANKI_WAIT_69_KABE: 12, 

1024 TANKI_WAIT_69_SUJI: 12, 

1025 TANKI_WAIT_28_KABE: 11, 

1026 TANKI_WAIT_28_SUJI: 11, 

1027 TANKI_WAIT_37_KABE: 10, 

1028 TANKI_WAIT_37_SUJI: 10, 

1029 TANKI_WAIT_69_RAW: 9, 

1030 TANKI_WAIT_456_KABE: 8, 

1031 TANKI_WAIT_456_SUJI: 8, 

1032 TANKI_WAIT_28_RAW: 7, 

1033 TANKI_WAIT_456_RAW: 6, 

1034 TANKI_WAIT_37_RAW: 5, 

1035 } 

1036 

1037 tanki_wait_diff_ukeire_prio = { 

1038 TANKI_WAIT_NON_YAKUHAI: 1, 

1039 TANKI_WAIT_SELF_YAKUHAI: 1, 

1040 TANKI_WAIT_ALL_YAKUHAI: 1, 

1041 TANKI_WAIT_69_KABE: 1, 

1042 TANKI_WAIT_69_SUJI: 1, 

1043 TANKI_WAIT_28_KABE: 0, 

1044 TANKI_WAIT_28_SUJI: 0, 

1045 TANKI_WAIT_37_KABE: 0, 

1046 TANKI_WAIT_37_SUJI: 0, 

1047 TANKI_WAIT_69_RAW: 0, 

1048 TANKI_WAIT_456_KABE: 0, 

1049 TANKI_WAIT_456_SUJI: 0, 

1050 TANKI_WAIT_28_RAW: 0, 

1051 TANKI_WAIT_456_RAW: 0, 

1052 TANKI_WAIT_37_RAW: 0, 

1053 }