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

49 """ 

50 Based on player hand and table situation 

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

52 """ 

53 self.calculate_dora_count(tiles_136) 

54 return True 

55 

56 def can_meld_into_agari(self) -> bool: 

57 """ 

58 Is melding into agari allowed with this strategy. 

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

60 non-suitable tiles, we can meld into agari state, 

61 because we'll throw them away after meld. 

62 Otherwise, there is no point. 

63 """ 

64 for tile in self.player.tiles: 

65 if not self.is_tile_suitable(tile): 

66 return True 

67 return False 

68 

69 def is_tile_suitable(self, tile): 

70 """ 

71 Can tile be used for open hand strategy or not 

72 :param tile: in 136 tiles format 

73 :return: boolean 

74 """ 

75 raise NotImplementedError() 

76 

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

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

79 shanten = first_option.shanten 

80 

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

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

83 return discard_options 

84 

85 # mark all not suitable tiles as ready to discard 

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

87 for x in discard_options: 

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

89 x.had_to_be_discarded = True 

90 

91 return discard_options 

92 

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

94 """ 

95 Determine should we call a meld or not. 

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

97 :param tile: 136 format tile 

98 :param is_kamicha_discard: boolean 

99 :param new_tiles: 

100 :return: MeldPrint and DiscardOption objects 

101 """ 

102 if self.player.in_riichi: 

103 return None, None 

104 

105 closed_hand = self.player.closed_hand[:] 

106 

107 # we can't open hand anymore 

108 if len(closed_hand) == 1: 

109 return None, None 

110 

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

112 if not self.is_tile_suitable(tile): 

113 return None, None 

114 

115 discarded_tile = tile // 4 

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

117 

118 combinations = [] 

119 first_index = 0 

120 second_index = 0 

121 if is_man(discarded_tile): 

122 first_index = 0 

123 second_index = 8 

124 elif is_pin(discarded_tile): 

125 first_index = 9 

126 second_index = 17 

127 elif is_sou(discarded_tile): 

128 first_index = 18 

129 second_index = 26 

130 

131 if second_index == 0: 

132 # honor tiles 

133 if closed_hand_34[discarded_tile] == 3: 

134 combinations = [[[discarded_tile] * 3]] 

135 else: 

136 # to avoid not necessary calculations 

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

138 first_limit = discarded_tile - 2 

139 if first_limit < first_index: 

140 first_limit = first_index 

141 

142 second_limit = discarded_tile + 2 

143 if second_limit > second_index: 

144 second_limit = second_index 

145 

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

147 closed_hand_34, first_limit, second_limit, True 

148 ) 

149 

150 if combinations: 

151 combinations = combinations[0] 

152 

153 possible_melds = [] 

154 for best_meld_34 in combinations: 

155 # we can call pon from everyone 

156 if is_pon(best_meld_34) and discarded_tile in best_meld_34: 

157 if best_meld_34 not in possible_melds: 

158 possible_melds.append(best_meld_34) 

159 

160 # we can call chi only from left player 

161 if is_chi(best_meld_34) and is_kamicha_discard 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 melds only with allowed tiles 

166 validated_melds = [] 

167 for meld in possible_melds: 

168 if ( 

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

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

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

172 ): 

173 validated_melds.append(meld) 

174 possible_melds = validated_melds 

175 

176 if not possible_melds: 

177 return None, None 

178 

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

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

181 if not chosen_meld_dict: 

182 return None, None 

183 

184 selected_tile = chosen_meld_dict["discard_tile"] 

185 meld = chosen_meld_dict["meld"] 

186 

187 shanten = selected_tile.shanten 

188 had_to_be_called = self.meld_had_to_be_called(tile) 

189 had_to_be_called = had_to_be_called or selected_tile.had_to_be_discarded 

190 

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

192 if shanten > self.min_shanten: 

193 self.player.logger.debug( 

194 log.MELD_DEBUG, 

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

196 ) 

197 return None, None 

198 

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

200 # otherwise we can call only with improvements of shanten 

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

202 self.player.logger.debug( 

203 log.MELD_DEBUG, 

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

205 ) 

206 return None, None 

207 

208 if not self.validate_meld(chosen_meld_dict): 

209 self.player.logger.debug( 

210 log.MELD_DEBUG, 

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

212 ) 

213 return None, None 

214 

215 if not self.should_push_against_threats(chosen_meld_dict): 

216 self.player.logger.debug( 

217 log.MELD_DEBUG, 

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

219 ) 

220 return None, None 

221 

222 return meld, selected_tile 

223 

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

225 selected_tile = chosen_meld_dict["discard_tile"] 

226 

227 if selected_tile.shanten <= 1: 

228 return True 

229 

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

231 if not threats: 

232 return True 

233 

234 # don't open garbage hand against threats 

235 if selected_tile.shanten >= 3: 

236 return False 

237 

238 tile_136 = selected_tile.tile_to_discard_136 

239 if len(threats) == 1: 

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

241 # expensive threat 

242 # and our hand is not good 

243 # let's not open this 

244 if threat_hand_cost >= 7700: 

245 return False 

246 else: 

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

248 # 2+ threats 

249 # and they are not cheap 

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

251 if min_threat_hand_cost >= 5200: 

252 return False 

253 

254 return True 

255 

256 def validate_meld(self, chosen_meld_dict): 

257 """ 

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

259 """ 

260 if self.player.is_open_hand: 

261 return True 

262 

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

264 return True 

265 

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

267 if self.player.is_dealer: 

268 return True 

269 

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

271 if not placement: 

272 return True 

273 

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

275 if needed_cost <= 1000: 

276 return True 

277 

278 selected_tile = chosen_meld_dict["discard_tile"] 

279 if selected_tile.ukeire == 0: 

280 self.player.logger.debug( 

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

282 ) 

283 return False 

284 

285 logger_context = { 

286 "placement": placement, 

287 "meld": chosen_meld_dict, 

288 "needed_cost": needed_cost, 

289 } 

290 

291 if selected_tile.shanten == 0: 

292 if not selected_tile.tempai_descriptor: 

293 return True 

294 

295 # tempai has special logger context 

296 logger_context = { 

297 "placement": placement, 

298 "meld": chosen_meld_dict, 

299 "needed_cost": needed_cost, 

300 "tempai_descriptor": selected_tile.tempai_descriptor, 

301 } 

302 

303 if selected_tile.tempai_descriptor["hand_cost"]: 

304 hand_cost = selected_tile.tempai_descriptor["hand_cost"] 

305 else: 

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

307 

308 # optimistic condition - direct ron 

309 if hand_cost * 2 < needed_cost: 

310 self.player.logger.debug( 

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

312 ) 

313 return False 

314 elif selected_tile.shanten == 1: 

315 if selected_tile.average_second_level_cost is None: 

316 return True 

317 

318 # optimistic condition - direct ron 

319 if selected_tile.average_second_level_cost * 2 < needed_cost: 

320 self.player.logger.debug( 

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

322 ) 

323 return False 

324 else: 

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

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

327 if num_han < len(simple_han_scale): 

328 hand_cost = simple_han_scale[num_han] 

329 # optimistic condition - direct ron 

330 if hand_cost * 2 < needed_cost: 

331 self.player.logger.debug( 

332 log.MELD_DEBUG, 

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

334 logger_context, 

335 ) 

336 return False 

337 

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

339 return True 

340 

341 def meld_had_to_be_called(self, tile): 

342 """ 

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

344 :param tile: in 136 tiles format 

345 :return: boolean 

346 """ 

347 return False 

348 

349 def calculate_dora_count(self, tiles_136): 

350 self.dora_count_central = 0 

351 self.dora_count_not_central = 0 

352 self.aka_dora_count = 0 

353 

354 for tile_136 in tiles_136: 

355 tile_34 = tile_136 // 4 

356 

357 dora_count = plus_dora( 

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

359 ) 

360 

361 if not dora_count: 

362 continue 

363 

364 if is_honor(tile_34): 

365 self.dora_count_not_central += dora_count 

366 self.dora_count_honor += dora_count 

367 elif is_terminal(tile_34): 

368 self.dora_count_not_central += dora_count 

369 else: 

370 self.dora_count_central += dora_count 

371 

372 self.dora_count_central += self.aka_dora_count 

373 self.dora_count_total = self.dora_count_central + self.dora_count_not_central 

374 

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

376 all_tiles_are_suitable = True 

377 for tile_136 in closed_hand: 

378 all_tiles_are_suitable &= self.is_tile_suitable(tile_136) 

379 

380 final_results = [] 

381 for meld_34 in possible_melds: 

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

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

384 melds_original = self.player.melds[:] 

385 tiles_original = self.player.tiles[:] 

386 

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

388 meld = MeldPrint() 

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

390 meld.tiles = sorted(tiles) 

391 

392 self.player.logger.debug( 

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

394 ) 

395 

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

397 self.player.tiles = new_tiles[:] 

398 self.player.add_called_meld(meld) 

399 

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

401 closed_hand_tiles_after_meld = self.player.closed_hand[:] 

402 

403 # restore original tiles and melds state 

404 self.player.tiles = tiles_original 

405 self.player.melds = melds_original 

406 

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

408 if not selected_tile: 

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

410 continue 

411 

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

413 self.player.logger.debug( 

414 log.MELD_DEBUG, 

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

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

417 ) 

418 continue 

419 

420 call_tile_34 = call_tile_136 // 4 

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

422 if selected_tile.tile_to_discard_34 == call_tile_34: 

423 self.player.logger.debug( 

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

425 ) 

426 continue 

427 

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

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

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

431 same_suit = True 

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

433 same_suit = True 

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

435 same_suit = True 

436 else: 

437 same_suit = False 

438 

439 if same_suit: 

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

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

442 simplified_call = simplify(call_tile_34) 

443 simplified_discard = simplify(selected_tile.tile_to_discard_34) 

444 kuikae = False 

445 if simplified_discard == simplified_call - 3: 

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

447 if simplified_meld_0 in kuikae_set and simplified_meld_1 in kuikae_set: 

448 kuikae = True 

449 elif 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 

454 if kuikae: 

455 tile_str = TilesConverter.to_one_line_string( 

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

457 ) 

458 self.player.logger.debug( 

459 log.MELD_DEBUG, 

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

461 ) 

462 continue 

463 

464 final_results.append( 

465 { 

466 "discard_tile": selected_tile, 

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

468 "meld": meld, 

469 "closed_hand_tiles_after_meld": closed_hand_tiles_after_meld, 

470 } 

471 ) 

472 

473 if not final_results: 

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

475 return None 

476 

477 final_results = sorted( 

478 final_results, 

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

480 ) 

481 

482 self.player.logger.debug( 

483 log.MELD_PREPARE, 

484 "Tiles could be used for open meld", 

485 context=final_results, 

486 ) 

487 return final_results[0] 

488 

489 @staticmethod 

490 def _find_meld_tiles(closed_hand, meld_34, discarded_tile): 

491 discarded_tile_34 = discarded_tile // 4 

492 meld_34_copy = meld_34[:] 

493 closed_hand_copy = closed_hand[:] 

494 

495 meld_34_copy.remove(discarded_tile_34) 

496 

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

498 closed_hand_copy.remove(first_tile) 

499 

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

501 closed_hand_copy.remove(second_tile) 

502 

503 tiles = [first_tile, second_tile, discarded_tile] 

504 

505 return tiles 

506 

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

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

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

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

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

512 ", ".join( 

513 [ 

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

515 for x in melds 

516 ] 

517 ) 

518 ) 

519 return hand_string