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

1import utils.decisions_constants as log 

2from mahjong.tile import TilesConverter 

3from mahjong.utils import is_chi, is_honor, is_man, is_pin, is_pon, is_sou, is_terminal, plus_dora, simplify 

4from utils.decisions_logger import MeldPrint 

5 

6 

7class BaseStrategy: 

8 YAKUHAI = 0 

9 HONITSU = 1 

10 TANYAO = 2 

11 FORMAL_TEMPAI = 3 

12 CHINITSU = 4 

13 COMMON_OPEN_TEMPAI = 6 

14 

15 TYPES = { 

16 YAKUHAI: "Yakuhai", 

17 HONITSU: "Honitsu", 

18 TANYAO: "Tanyao", 

19 FORMAL_TEMPAI: "Formal Tempai", 

20 CHINITSU: "Chinitsu", 

21 COMMON_OPEN_TEMPAI: "Common Open Tempai", 

22 } 

23 

24 not_suitable_tiles = [] 

25 player = None 

26 type = None 

27 # number of shanten where we can start to open hand 

28 min_shanten = 7 

29 go_for_atodzuke = False 

30 

31 dora_count_total = 0 

32 dora_count_central = 0 

33 dora_count_not_central = 0 

34 aka_dora_count = 0 

35 dora_count_honor = 0 

36 

37 def __init__(self, strategy_type, player): 

38 self.type = strategy_type 

39 self.player = player 

40 self.go_for_atodzuke = False 

41 

42 def __str__(self): 

43 return self.TYPES[self.type] 

44 

45 def get_open_hand_han(self): 

46 return 0 

47 

48 def should_activate_strategy(self, tiles_136, meld_tile=None): 

49 """ 

50 Based on player hand and table situation 

51 we can determine should we use this strategy or not. 

52 :param: tiles_136 

53 :return: boolean 

54 """ 

55 self.calculate_dora_count(tiles_136) 

56 

57 return True 

58 

59 def can_meld_into_agari(self): 

60 """ 

61 Is melding into agari allowed with this strategy 

62 :return: boolean 

63 """ 

64 # By default, the logic is the following: if we have any 

65 # non-suitable tiles, we can meld into agari state, because we'll 

66 # throw them away after meld. 

67 # Otherwise, there is no point. 

68 for tile in self.player.tiles: 

69 if not self.is_tile_suitable(tile): 

70 return True 

71 

72 return False 

73 

74 def is_tile_suitable(self, tile): 

75 """ 

76 Can tile be used for open hand strategy or not 

77 :param tile: in 136 tiles format 

78 :return: boolean 

79 """ 

80 raise NotImplementedError() 

81 

82 def determine_what_to_discard(self, discard_options, hand, open_melds): 

83 first_option = sorted(discard_options, key=lambda x: x.shanten)[0] 

84 shanten = first_option.shanten 

85 

86 # for riichi we don't need to discard useful tiles 

87 if shanten == 0 and not self.player.is_open_hand: 

88 return discard_options 

89 

90 # mark all not suitable tiles as ready to discard 

91 # even if they not should be discarded by uke-ire 

92 for x in discard_options: 

93 if not self.is_tile_suitable(x.tile_to_discard_136): 

94 x.had_to_be_discarded = True 

95 

96 return discard_options 

97 

98 def try_to_call_meld(self, tile, is_kamicha_discard, new_tiles): 

99 """ 

100 Determine should we call a meld or not. 

101 If yes, it will return MeldPrint object and tile to discard 

102 :param tile: 136 format tile 

103 :param is_kamicha_discard: boolean 

104 :param new_tiles: 

105 :return: MeldPrint and DiscardOption objects 

106 """ 

107 if self.player.in_riichi: 

108 return None, None 

109 

110 closed_hand = self.player.closed_hand[:] 

111 

112 # we can't open hand anymore 

113 if len(closed_hand) == 1: 

114 return None, None 

115 

116 # we can't use this tile for our chosen strategy 

117 if not self.is_tile_suitable(tile): 

118 return None, None 

119 

120 discarded_tile = tile // 4 

121 closed_hand_34 = TilesConverter.to_34_array(closed_hand + [tile]) 

122 

123 combinations = [] 

124 first_index = 0 

125 second_index = 0 

126 if is_man(discarded_tile): 

127 first_index = 0 

128 second_index = 8 

129 elif is_pin(discarded_tile): 

130 first_index = 9 

131 second_index = 17 

132 elif is_sou(discarded_tile): 

133 first_index = 18 

134 second_index = 26 

135 

136 if second_index == 0: 

137 # honor tiles 

138 if closed_hand_34[discarded_tile] == 3: 

139 combinations = [[[discarded_tile] * 3]] 

140 else: 

141 # to avoid not necessary calculations 

142 # we can check only tiles around +-2 discarded tile 

143 first_limit = discarded_tile - 2 

144 if first_limit < first_index: 

145 first_limit = first_index 

146 

147 second_limit = discarded_tile + 2 

148 if second_limit > second_index: 

149 second_limit = second_index 

150 

151 combinations = self.player.ai.hand_divider.find_valid_combinations( 

152 closed_hand_34, first_limit, second_limit, True 

153 ) 

154 

155 if combinations: 

156 combinations = combinations[0] 

157 

158 possible_melds = [] 

159 for best_meld_34 in combinations: 

160 # we can call pon from everyone 

161 if is_pon(best_meld_34) and discarded_tile in best_meld_34: 

162 if best_meld_34 not in possible_melds: 

163 possible_melds.append(best_meld_34) 

164 

165 # we can call chi only from left player 

166 if is_chi(best_meld_34) and is_kamicha_discard and discarded_tile in best_meld_34: 

167 if best_meld_34 not in possible_melds: 

168 possible_melds.append(best_meld_34) 

169 

170 # we can call melds only with allowed tiles 

171 validated_melds = [] 

172 for meld in possible_melds: 

173 if ( 

174 self.is_tile_suitable(meld[0] * 4) 

175 and self.is_tile_suitable(meld[1] * 4) 

176 and self.is_tile_suitable(meld[2] * 4) 

177 ): 

178 validated_melds.append(meld) 

179 possible_melds = validated_melds 

180 

181 if not possible_melds: 

182 return None, None 

183 

184 chosen_meld_dict = self._find_best_meld_to_open(tile, possible_melds, new_tiles, closed_hand, tile) 

185 # we didn't find a good discard candidate after open meld 

186 if not chosen_meld_dict: 

187 return None, None 

188 

189 selected_tile = chosen_meld_dict["discard_tile"] 

190 meld = chosen_meld_dict["meld"] 

191 

192 shanten = selected_tile.shanten 

193 had_to_be_called = self.meld_had_to_be_called(tile) 

194 had_to_be_called = had_to_be_called or selected_tile.had_to_be_discarded 

195 

196 # each strategy can use their own value to min shanten number 

197 if shanten > self.min_shanten: 

198 self.player.logger.debug( 

199 log.MELD_DEBUG, 

200 "After meld shanten is too high for our strategy. Abort melding.", 

201 ) 

202 return None, None 

203 

204 # sometimes we had to call tile, even if it will not improve our hand 

205 # otherwise we can call only with improvements of shanten 

206 if not had_to_be_called and shanten >= self.player.ai.shanten: 

207 self.player.logger.debug( 

208 log.MELD_DEBUG, 

209 "Meld is not improving hand shanten. Abort melding.", 

210 ) 

211 return None, None 

212 

213 if not self.validate_meld(chosen_meld_dict): 

214 self.player.logger.debug( 

215 log.MELD_DEBUG, 

216 "Meld is suitable for strategy logic. Abort melding.", 

217 ) 

218 return None, None 

219 

220 if not self.should_push_against_threats(chosen_meld_dict): 

221 self.player.logger.debug( 

222 log.MELD_DEBUG, 

223 "Meld is too dangerous to call. Abort melding.", 

224 ) 

225 return None, None 

226 

227 return meld, selected_tile 

228 

229 def should_push_against_threats(self, chosen_meld_dict) -> bool: 

230 selected_tile = chosen_meld_dict["discard_tile"] 

231 

232 if selected_tile.shanten <= 1: 

233 return True 

234 

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

236 if not threats: 

237 return True 

238 

239 # don't open garbage hand against threats 

240 if selected_tile.shanten >= 3: 

241 return False 

242 

243 tile_136 = selected_tile.tile_to_discard_136 

244 if len(threats) == 1: 

245 threat_hand_cost = threats[0].get_assumed_hand_cost(tile_136) 

246 # expensive threat 

247 # and our hand is not good 

248 # let's not open this 

249 if threat_hand_cost >= 7700: 

250 return False 

251 else: 

252 min_threat_hand_cost = min([x.get_assumed_hand_cost(tile_136) for x in threats]) 

253 # 2+ threats 

254 # and they are not cheap 

255 # so, let's skip opening of bad hand 

256 if min_threat_hand_cost >= 5200: 

257 return False 

258 

259 return True 

260 

261 def validate_meld(self, chosen_meld_dict): 

262 """ 

263 In some cased we want additionally check that meld is suitable to the strategy 

264 """ 

265 if self.player.is_open_hand: 

266 return True 

267 

268 if not self.player.ai.placement.is_oorasu: 

269 return True 

270 

271 # don't care about not enough cost if we are the dealer 

272 if self.player.is_dealer: 

273 return True 

274 

275 placement = self.player.ai.placement.get_current_placement() 

276 if not placement: 

277 return True 

278 

279 needed_cost = self.player.ai.placement.get_minimal_cost_needed_considering_west(placement=placement) 

280 if needed_cost <= 1000: 

281 return True 

282 

283 selected_tile = chosen_meld_dict["discard_tile"] 

284 if selected_tile.ukeire == 0: 

285 self.player.logger.debug( 

286 log.MELD_DEBUG, "We need to get out of 4th place, but this meld leaves us with zero ukeire" 

287 ) 

288 return False 

289 

290 logger_context = { 

291 "placement": placement, 

292 "meld": chosen_meld_dict, 

293 "needed_cost": needed_cost, 

294 } 

295 

296 if selected_tile.shanten == 0: 

297 if not selected_tile.tempai_descriptor: 

298 return True 

299 

300 # tempai has special logger context 

301 logger_context = { 

302 "placement": placement, 

303 "meld": chosen_meld_dict, 

304 "needed_cost": needed_cost, 

305 "tempai_descriptor": selected_tile.tempai_descriptor, 

306 } 

307 

308 if selected_tile.tempai_descriptor["hand_cost"]: 

309 hand_cost = selected_tile.tempai_descriptor["hand_cost"] 

310 else: 

311 hand_cost = selected_tile.tempai_descriptor["cost_x_ukeire"] / selected_tile.ukeire 

312 

313 # optimistic condition - direct ron 

314 if hand_cost * 2 < needed_cost: 

315 self.player.logger.debug( 

316 log.MELD_DEBUG, "No chance to comeback from 4th with this meld, so keep hand closed", logger_context 

317 ) 

318 return False 

319 elif selected_tile.shanten == 1: 

320 if selected_tile.average_second_level_cost is None: 

321 return True 

322 

323 # optimistic condition - direct ron 

324 if selected_tile.average_second_level_cost * 2 < needed_cost: 

325 self.player.logger.debug( 

326 log.MELD_DEBUG, "No chance to comeback from 4th with this meld, so keep hand closed", logger_context 

327 ) 

328 return False 

329 else: 

330 simple_han_scale = [0, 1000, 2000, 3900, 7700, 8000, 12000, 12000] 

331 num_han = self.get_open_hand_han() + self.dora_count_total 

332 if num_han < len(simple_han_scale): 

333 hand_cost = simple_han_scale[num_han] 

334 # optimistic condition - direct ron 

335 if hand_cost * 2 < needed_cost: 

336 self.player.logger.debug( 

337 log.MELD_DEBUG, 

338 "No chance to comeback from 4th with this meld, so keep hand closed", 

339 logger_context, 

340 ) 

341 return False 

342 

343 self.player.logger.debug(log.MELD_DEBUG, "This meld should allow us to comeback from 4th", logger_context) 

344 return True 

345 

346 def meld_had_to_be_called(self, tile): 

347 """ 

348 For special cases meld had to be called even if shanten number will not be increased 

349 :param tile: in 136 tiles format 

350 :return: boolean 

351 """ 

352 return False 

353 

354 def calculate_dora_count(self, tiles_136): 

355 self.dora_count_central = 0 

356 self.dora_count_not_central = 0 

357 self.aka_dora_count = 0 

358 

359 for tile_136 in tiles_136: 

360 tile_34 = tile_136 // 4 

361 

362 dora_count = plus_dora( 

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

364 ) 

365 

366 if not dora_count: 

367 continue 

368 

369 if is_honor(tile_34): 

370 self.dora_count_not_central += dora_count 

371 self.dora_count_honor += dora_count 

372 elif is_terminal(tile_34): 

373 self.dora_count_not_central += dora_count 

374 else: 

375 self.dora_count_central += dora_count 

376 

377 self.dora_count_central += self.aka_dora_count 

378 self.dora_count_total = self.dora_count_central + self.dora_count_not_central 

379 

380 def _find_best_meld_to_open(self, call_tile_136, possible_melds, new_tiles, closed_hand, discarded_tile): 

381 all_tiles_are_suitable = True 

382 for tile_136 in closed_hand: 

383 all_tiles_are_suitable &= self.is_tile_suitable(tile_136) 

384 

385 final_results = [] 

386 for meld_34 in possible_melds: 

387 # in order to fully emulate the possible hand with meld, we save original melds state, 

388 # modify player's melds and then restore original melds state after everything is done 

389 melds_original = self.player.melds[:] 

390 tiles_original = self.player.tiles[:] 

391 

392 tiles = self._find_meld_tiles(closed_hand, meld_34, discarded_tile) 

393 meld = MeldPrint() 

394 meld.type = is_chi(meld_34) and MeldPrint.CHI or MeldPrint.PON 

395 meld.tiles = sorted(tiles) 

396 

397 self.player.logger.debug( 

398 log.MELD_HAND, f"Hand: {self._format_hand_for_print(closed_hand, discarded_tile, self.player.melds)}" 

399 ) 

400 

401 # update player hand state to emulate new situation and choose what to discard 

402 self.player.tiles = new_tiles[:] 

403 self.player.add_called_meld(meld) 

404 

405 selected_tile = self.player.ai.hand_builder.choose_tile_to_discard(after_meld=True) 

406 

407 # restore original tiles and melds state 

408 self.player.tiles = tiles_original 

409 self.player.melds = melds_original 

410 

411 # we can't find a good discard candidate, so let's skip this 

412 if not selected_tile: 

413 self.player.logger.debug(log.MELD_DEBUG, "Can't find discard candidate after meld. Abort melding.") 

414 continue 

415 

416 if not all_tiles_are_suitable and self.is_tile_suitable(selected_tile.tile_to_discard_136): 

417 self.player.logger.debug( 

418 log.MELD_DEBUG, 

419 "We have tiles in our hand that are not suitable to current strategy, " 

420 "but we are going to discard tile that we need. Abort melding.", 

421 ) 

422 continue 

423 

424 call_tile_34 = call_tile_136 // 4 

425 # we can't discard the same tile that we called 

426 if selected_tile.tile_to_discard_34 == call_tile_34: 

427 self.player.logger.debug( 

428 log.MELD_DEBUG, "We can't discard same tile that we used for meld. Abort melding." 

429 ) 

430 continue 

431 

432 # we can't discard tile from the other end of the same ryanmen that we called 

433 if not is_honor(selected_tile.tile_to_discard_34) and meld.type == MeldPrint.CHI: 

434 if is_sou(selected_tile.tile_to_discard_34) and is_sou(call_tile_34): 

435 same_suit = True 

436 elif is_man(selected_tile.tile_to_discard_34) and is_man(call_tile_34): 

437 same_suit = True 

438 elif is_pin(selected_tile.tile_to_discard_34) and is_pin(call_tile_34): 

439 same_suit = True 

440 else: 

441 same_suit = False 

442 

443 if same_suit: 

444 simplified_meld_0 = simplify(meld.tiles[0] // 4) 

445 simplified_meld_1 = simplify(meld.tiles[1] // 4) 

446 simplified_call = simplify(call_tile_34) 

447 simplified_discard = simplify(selected_tile.tile_to_discard_34) 

448 kuikae = False 

449 if simplified_discard == simplified_call - 3: 

450 kuikae_set = [simplified_call - 1, simplified_call - 2] 

451 if simplified_meld_0 in kuikae_set and simplified_meld_1 in kuikae_set: 

452 kuikae = True 

453 elif simplified_discard == simplified_call + 3: 

454 kuikae_set = [simplified_call + 1, simplified_call + 2] 

455 if simplified_meld_0 in kuikae_set and simplified_meld_1 in kuikae_set: 

456 kuikae = True 

457 

458 if kuikae: 

459 tile_str = TilesConverter.to_one_line_string( 

460 [selected_tile.tile_to_discard_136], print_aka_dora=self.player.table.has_aka_dora 

461 ) 

462 self.player.logger.debug( 

463 log.MELD_DEBUG, 

464 f"Kuikae discard {tile_str} candidate. Abort melding.", 

465 ) 

466 continue 

467 

468 final_results.append( 

469 { 

470 "discard_tile": selected_tile, 

471 "meld_print": TilesConverter.to_one_line_string([meld_34[0] * 4, meld_34[1] * 4, meld_34[2] * 4]), 

472 "meld": meld, 

473 } 

474 ) 

475 

476 if not final_results: 

477 self.player.logger.debug(log.MELD_DEBUG, "There are no good discards after melding.") 

478 return None 

479 

480 final_results = sorted( 

481 final_results, 

482 key=lambda x: (x["discard_tile"].shanten, -x["discard_tile"].ukeire, x["discard_tile"].valuation), 

483 ) 

484 

485 self.player.logger.debug( 

486 log.MELD_PREPARE, 

487 "Tiles could be used for open meld", 

488 context=final_results, 

489 ) 

490 return final_results[0] 

491 

492 @staticmethod 

493 def _find_meld_tiles(closed_hand, meld_34, discarded_tile): 

494 discarded_tile_34 = discarded_tile // 4 

495 meld_34_copy = meld_34[:] 

496 closed_hand_copy = closed_hand[:] 

497 

498 meld_34_copy.remove(discarded_tile_34) 

499 

500 first_tile = TilesConverter.find_34_tile_in_136_array(meld_34_copy[0], closed_hand_copy) 

501 closed_hand_copy.remove(first_tile) 

502 

503 second_tile = TilesConverter.find_34_tile_in_136_array(meld_34_copy[1], closed_hand_copy) 

504 closed_hand_copy.remove(second_tile) 

505 

506 tiles = [first_tile, second_tile, discarded_tile] 

507 

508 return tiles 

509 

510 def _format_hand_for_print(self, tiles, new_tile, melds): 

511 tiles_string = TilesConverter.to_one_line_string(tiles, print_aka_dora=self.player.table.has_aka_dora) 

512 tile_string = TilesConverter.to_one_line_string([new_tile], print_aka_dora=self.player.table.has_aka_dora) 

513 hand_string = f"{tiles_string} + {tile_string}" 

514 hand_string += " [{}]".format( 

515 ", ".join( 

516 [ 

517 TilesConverter.to_one_line_string(x.tiles, print_aka_dora=self.player.table.has_aka_dora) 

518 for x in melds 

519 ] 

520 ) 

521 ) 

522 return hand_string