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 

3 

4 

5class PlacementHandler: 

6 def __init__(self, player): 

7 self.player = player 

8 self.table = player.table 

9 

10 def get_allowed_danger_modifier(self) -> int: 

11 placement = self.get_current_placement() 

12 placement_evaluation = self._get_placement_evaluation(placement) 

13 

14 if placement_evaluation == Placement.VERY_COMFORTABLE_FIRST: 

15 if self.is_late_round: 

16 self.player.logger.debug( 

17 log.PLACEMENT_DANGER_MODIFIER, 

18 "Very comfortable first and late round", 

19 {"placement": placement, "placement_evaluation": placement_evaluation}, 

20 ) 

21 return Placement.NO_RISK_DANGER_MODIFIER 

22 

23 self.player.logger.debug( 

24 log.PLACEMENT_DANGER_MODIFIER, 

25 "Very comfortable first and NOT late round", 

26 {"placement": placement, "placement_evaluation": placement_evaluation}, 

27 ) 

28 return Placement.MODERATE_DANGER_MODIFIER 

29 

30 if placement_evaluation == Placement.COMFORTABLE_FIRST: 

31 if self.is_late_round: 

32 self.player.logger.debug( 

33 log.PLACEMENT_DANGER_MODIFIER, 

34 "Comfortable first and late round", 

35 {"placement": placement, "placement_evaluation": placement_evaluation}, 

36 ) 

37 return Placement.MODERATE_DANGER_MODIFIER 

38 

39 return Placement.DEFAULT_DANGER_MODIFIER 

40 

41 # TODO: different logic for tournament games 

42 def must_riichi(self, has_yaku, num_waits, cost_with_riichi, cost_with_damaten) -> int: 

43 # now we only change our decisions for oorasu 

44 if not self.is_oorasu: 

45 return Placement.DEFAULT_RIICHI_DECISION 

46 

47 placement = self.get_current_placement() 

48 if not placement: 

49 return Placement.DEFAULT_RIICHI_DECISION 

50 

51 placement_evaluation = self._get_placement_evaluation(placement) 

52 

53 logger_context = { 

54 "placement": placement, 

55 "placement_evaluation": placement_evaluation, 

56 "has_yaku": has_yaku, 

57 "num_waits": num_waits, 

58 "cost_with_riichi": cost_with_riichi, 

59 "cost_with_damaten": cost_with_damaten, 

60 "round_step": self.player.round_step, 

61 } 

62 

63 if placement["place"] == 1: 

64 if has_yaku: 

65 self.player.logger.debug(log.PLACEMENT_RIICHI_OR_DAMATEN, "1st place, has yaku", logger_context) 

66 return Placement.MUST_DAMATEN 

67 

68 # no yaku but we can just sit here and chill 

69 if placement_evaluation >= Placement.VERY_COMFORTABLE_FIRST: 

70 self.player.logger.debug( 

71 log.PLACEMENT_RIICHI_OR_DAMATEN, "1st place, very comfortable first", logger_context 

72 ) 

73 return Placement.MUST_DAMATEN 

74 

75 if placement_evaluation >= Placement.COMFORTABLE_FIRST: 

76 # just chill 

77 if num_waits < 6 or self.player.round_step > 11: 

78 self.player.logger.debug( 

79 log.PLACEMENT_RIICHI_OR_DAMATEN, 

80 "1st place, comfortable first, late round, < 6 waits", 

81 logger_context, 

82 ) 

83 return Placement.MUST_DAMATEN 

84 

85 if placement["place"] == 2: 

86 if cost_with_damaten < placement["diff_with_1st"] <= cost_with_riichi * 2: 

87 if placement["diff_with_4th"] >= Placement.COMFORTABLE_DIFF_FOR_RISK: 

88 self.player.logger.debug( 

89 log.PLACEMENT_RIICHI_OR_DAMATEN, "2st place, we are good to risk", logger_context 

90 ) 

91 return Placement.MUST_RIICHI 

92 

93 # general rule for 2nd and 3rd places: 

94 if (placement["place"] == 2 or placement["place"] == 3) and has_yaku: 

95 # we can play more greedy on second place hoping for tsumo, ippatsu or uras 

96 # moreover riichi cost here is a minimal one 

97 if placement["place"] == 2: 

98 if placement["diff_with_4th"] <= 1000: 

99 multiplier = 2 

100 else: 

101 multiplier = 4 

102 else: 

103 multiplier = 2 

104 

105 if ( 

106 placement["diff_with_next_up"] > cost_with_riichi * multiplier 

107 and placement["diff_with_next_down"] <= 1000 

108 ): 

109 self.player.logger.debug(log.PLACEMENT_RIICHI_OR_DAMATEN, "not 4st place and has yaku", logger_context) 

110 return Placement.MUST_DAMATEN 

111 

112 if placement["place"] == 4: 

113 # TODO: consider going for better hand 

114 if cost_with_damaten < placement["diff_with_3rd"]: 

115 self.player.logger.debug(log.PLACEMENT_RIICHI_OR_DAMATEN, "4st place, let's riichi", logger_context) 

116 return Placement.MUST_RIICHI 

117 

118 return Placement.DEFAULT_RIICHI_DECISION 

119 

120 def should_call_win(self, cost, is_tsumo, enemy_seat): 

121 # we currently don't support win skipping for tsumo 

122 if is_tsumo: 

123 return True 

124 

125 # never skip win if we are the dealer 

126 if self.player.is_dealer: 

127 return True 

128 

129 placement = self.get_current_placement() 

130 if not placement: 

131 return True 

132 

133 needed_cost = self.get_minimal_cost_needed(placement=placement) 

134 if needed_cost == 0: 

135 return True 

136 

137 # currently we don't support logic other than for 4th place 

138 assert self.player == self.table.get_players_sorted_by_scores()[3] 

139 first_place = self.table.get_players_sorted_by_scores()[0] 

140 third_place = self.table.get_players_sorted_by_scores()[2] 

141 

142 num_players_over_30000 = len([x for x in self.table.players if x.scores >= 30000]) 

143 direct_hit_cost = cost["main"] + cost["main_bonus"] 

144 if enemy_seat == third_place.seat: 

145 covered_cost = direct_hit_cost * 2 + cost["kyoutaku_bonus"] 

146 else: 

147 covered_cost = cost["total"] 

148 

149 logger_context = { 

150 "placement": placement, 

151 "needed_cost": needed_cost, 

152 "covered_cost": covered_cost, 

153 "is_tsumo": is_tsumo, 

154 "closest_enemy_seat": third_place.seat, 

155 "enemy_seat_ron": enemy_seat, 

156 "num_players_over_30000": num_players_over_30000, 

157 } 

158 

159 if not self.is_west_4: 

160 # check if we can make it to the west round 

161 if num_players_over_30000 == 0: 

162 self.player.logger.debug(log.AGARI, "Decided to take ron when no enemies have 30k", logger_context) 

163 return True 

164 

165 if num_players_over_30000 == 1: 

166 if enemy_seat == first_place.seat: 

167 if first_place.scores < 30000 + direct_hit_cost: 

168 self.player.logger.debug( 

169 log.AGARI, "Decided to take ron from first place, so no enemies have 30k", logger_context 

170 ) 

171 return True 

172 

173 if covered_cost < needed_cost: 

174 self.player.logger.debug(log.AGARI, "Decided to skip ron", logger_context) 

175 return False 

176 

177 self.player.logger.debug(log.AGARI, "Decided to take ron for better placement", logger_context) 

178 return True 

179 

180 def get_minimal_cost_needed(self, placement=None): 

181 if not self.is_oorasu: 

182 return 0 

183 

184 if not placement: 

185 placement = self.get_current_placement() 

186 if not placement: 

187 return 0 

188 

189 if placement["place"] == 4: 

190 third_place = self.table.get_players_sorted_by_scores()[2] 

191 

192 extra = 0 

193 if self.player.first_seat > third_place.first_seat: 

194 extra = 100 

195 

196 return placement["diff_with_next_up"] + extra 

197 

198 return 0 

199 

200 def get_minimal_cost_needed_considering_west(self, placement=None) -> int: 

201 minimal_cost = self.get_minimal_cost_needed(placement) 

202 if not minimal_cost: 

203 return 0 

204 

205 if not placement: 

206 placement = self.get_current_placement() 

207 

208 if placement["place"] != 4: 

209 return minimal_cost 

210 

211 num_players_over_30000 = len([x for x in self.player.table.players if x.scores >= 30000]) 

212 if num_players_over_30000 == 0: 

213 return 1000 

214 

215 if num_players_over_30000 == 1: 

216 minimal_cost = min(minimal_cost, self.player.table.get_players_sorted_by_scores()[0].scores - 30000) 

217 

218 return minimal_cost 

219 

220 def get_current_placement(self): 

221 if not self.points_initialized: 

222 return None 

223 

224 players_by_points = self.table.get_players_sorted_by_scores() 

225 current_place = players_by_points.index(self.player) 

226 

227 return { 

228 "place": current_place + 1, 

229 "points": self.player.scores, 

230 "diff_with_1st": abs(self.player.scores - players_by_points[0].scores), 

231 "diff_with_2nd": abs(self.player.scores - players_by_points[1].scores), 

232 "diff_with_3rd": abs(self.player.scores - players_by_points[2].scores), 

233 "diff_with_4th": abs(self.player.scores - players_by_points[3].scores), 

234 "diff_with_next_up": abs(self.player.scores - players_by_points[max(0, current_place - 1)].scores), 

235 "diff_with_next_down": abs(self.player.scores - players_by_points[min(3, current_place + 1)].scores), 

236 } 

237 

238 def _get_placement_evaluation(self, placement) -> int: 

239 if not placement: 

240 return Placement.NEUTRAL 

241 

242 if placement["place"] == 1 and placement["points"] >= Placement.COMFORTABLE_POINTS: 

243 assert placement["diff_with_2nd"] >= 0 

244 if placement["diff_with_2nd"] >= Placement.VERY_COMFORTABLE_DIFF: 

245 return Placement.VERY_COMFORTABLE_FIRST 

246 

247 if placement["diff_with_2nd"] >= self.comfortable_diff: 

248 return Placement.COMFORTABLE_FIRST 

249 

250 return Placement.NEUTRAL 

251 

252 def must_push(self, threats, tile_136, num_shanten, tempai_cost=0) -> bool: 

253 if not self.is_oorasu: 

254 return False 

255 

256 if not threats: 

257 return False 

258 

259 placement = self.get_current_placement() 

260 if not placement: 

261 return False 

262 

263 logger_context = { 

264 "tile": TilesConverter.to_one_line_string([tile_136]), 

265 "shanten": num_shanten, 

266 "tempai_cost": tempai_cost, 

267 "placement": placement, 

268 } 

269 

270 # always push if we are 4th - nothing to lose 

271 if placement["place"] == 4: 

272 # TODO: more subtle rules are possible for rare situations 

273 self.player.logger.debug(log.PLACEMENT_PUSH_DECISION, "We are 4th in oorasu and must push", logger_context) 

274 return True 

275 

276 # don't force push with 2 or more shanten if we are not 4th 

277 if num_shanten > 1: 

278 return False 

279 

280 # if there are several threats let's follow our usual rules and otherwise hope that other player wins 

281 if len(threats) > 1: 

282 return False 

283 

284 # here we know there is exactly one threat 

285 threat = threats[0] 

286 players_by_points = self.table.get_players_sorted_by_scores() 

287 fourth_place = players_by_points[3] 

288 diff_with_4th = placement["diff_with_4th"] 

289 

290 if placement["place"] == 3: 

291 # 4th place is not a threat so we don't fear his win 

292 if threat.enemy != fourth_place: 

293 return False 

294 

295 # it's not _must_ to push against dealer, let's decide considering other factors 

296 if fourth_place.is_dealer: 

297 return False 

298 

299 if num_shanten == 0 and self.player.round_step < 10: 

300 # enemy player is gonna get us with tsumo mangan, let's attack if it's early 

301 if diff_with_4th < self.comfortable_diff: 

302 self.player.logger.debug( 

303 log.PLACEMENT_PUSH_DECISION, 

304 "We are 3rd in oorasu and must push in early game to secure 3rd place", 

305 logger_context, 

306 ) 

307 return True 

308 else: 

309 if diff_with_4th < Placement.RYUKOKU_MINIMUM_DIFF: 

310 self.player.logger.debug( 

311 log.PLACEMENT_PUSH_DECISION, 

312 "We are 3rd in oorasu and must push to secure 3rd place", 

313 logger_context, 

314 ) 

315 return True 

316 

317 return False 

318 

319 if placement["place"] == 2: 

320 if threat.enemy == fourth_place: 

321 if diff_with_4th < Placement.COMFORTABLE_DIFF_FOR_RISK + self.table_bonus_direct: 

322 return False 

323 

324 if placement["diff_with_3rd"] < self.comfortable_diff: 

325 return False 

326 

327 if num_shanten == 0: 

328 # we will push if we can get 1st with this hand with not much risk 

329 if placement["diff_with_1st"] <= tempai_cost + self.table_bonus_indirect: 

330 self.player.logger.debug( 

331 log.PLACEMENT_PUSH_DECISION, "We are 2nd in oorasu and must push to reach 1st", logger_context 

332 ) 

333 return True 

334 else: 

335 if placement["diff_with_1st"] <= tempai_cost + self.table_bonus_indirect: 

336 self.player.logger.debug( 

337 log.PLACEMENT_PUSH_DECISION, "We are 2nd in oorasu and must push to reach 1st", logger_context 

338 ) 

339 return True 

340 

341 return False 

342 

343 if placement["place"] == 1: 

344 second_place = players_by_points[1] 

345 if threat.enemy != second_place: 

346 return False 

347 

348 if placement["diff_with_3rd"] < self.comfortable_diff: 

349 return False 

350 

351 if num_shanten == 0 and self.player.round_step < 10: 

352 if placement["diff_with_2nd"] < self.comfortable_diff: 

353 self.player.logger.debug( 

354 log.PLACEMENT_PUSH_DECISION, 

355 "We are 1st in oorasu and must push in early game to secure 1st", 

356 logger_context, 

357 ) 

358 return True 

359 else: 

360 if placement["diff_with_2nd"] <= Placement.RYUKOKU_MINIMUM_DIFF: 

361 self.player.logger.debug( 

362 log.PLACEMENT_PUSH_DECISION, "We are 1st in oorasu and must push to secure 1st", logger_context 

363 ) 

364 return True 

365 

366 return False 

367 

368 # actually should never get here, but let's leave it in case we modify this code 

369 return False 

370 

371 @property 

372 def comfortable_diff(self) -> int: 

373 if self.player.is_dealer: 

374 base = Placement.COMFORTABLE_DIFF_DEALER 

375 else: 

376 base = Placement.COMFORTABLE_DIFF_NON_DEALER 

377 

378 bonus = self.table_bonus_tsumo 

379 

380 return base + bonus 

381 

382 @property 

383 def table_bonus_direct(self) -> int: 

384 return self.table.count_of_riichi_sticks * 1000 + self.table.count_of_honba_sticks * 600 

385 

386 @property 

387 def table_bonus_tsumo(self) -> int: 

388 return self.table.count_of_riichi_sticks * 1000 + self.table.count_of_honba_sticks * 400 

389 

390 @property 

391 def table_bonus_indirect(self) -> int: 

392 return self.table.count_of_riichi_sticks * 1000 + self.table.count_of_honba_sticks * 300 

393 

394 @property 

395 def points_initialized(self): 

396 if [x for x in self.table.get_players_sorted_by_scores() if x.scores is None]: 

397 return False 

398 return True 

399 

400 @property 

401 def is_oorasu(self): 

402 # TODO: consider tonpu 

403 return self.table.round_wind_number >= 7 

404 

405 @property 

406 def is_west_4(self): 

407 # TODO: consider tonpu 

408 return self.table.round_wind_number == 11 

409 

410 @property 

411 def is_late_round(self): 

412 # TODO: consider tonpu 

413 return self.table.round_wind_number >= 6 

414 

415 

416class DummyPlacementHandler(PlacementHandler): 

417 """ 

418 Use this class in config if you want to disable placement logic for bot 

419 """ 

420 

421 def get_allowed_danger_modifier(self) -> int: 

422 return Placement.DEFAULT_DANGER_MODIFIER 

423 

424 def must_riichi(self, has_yaku, num_waits, cost_with_riichi, cost_with_damaten) -> int: 

425 return Placement.DEFAULT_RIICHI_DECISION 

426 

427 def _get_placement_evaluation(self, placement) -> int: 

428 return Placement.NEUTRAL 

429 

430 def should_call_win(self, cost, is_tsumo, enemy_seat): 

431 return True 

432 

433 def get_minimal_cost_needed(self, placement=None) -> int: 

434 return 0 

435 

436 def get_minimal_cost_needed_considering_west(self, placement=None) -> int: 

437 return 0 

438 

439 def must_push(self, threats, tile_136, num_shanten, tempai_cost=0) -> bool: 

440 return False 

441 

442 @property 

443 def comfortable_diff(self) -> int: 

444 return 0 

445 

446 

447class Placement: 

448 # TODO: account for honbas and riichi sticks on the table 

449 VERY_COMFORTABLE_DIFF = 24100 

450 COMFORTABLE_DIFF_FOR_RISK = 18100 

451 

452 COMFORTABLE_DIFF_DEALER = 12100 

453 COMFORTABLE_DIFF_NON_DEALER = 10100 

454 

455 COMFORTABLE_POINTS = 38000 

456 

457 RYUKOKU_MINIMUM_DIFF = 4000 

458 

459 # player position in the game 

460 # must go in ascending order from bad to good, so we can use <, > operators with them 

461 NEUTRAL = 0 

462 COMFORTABLE_FIRST = 1 

463 VERY_COMFORTABLE_FIRST = 2 

464 

465 # riichi definitions 

466 DEFAULT_RIICHI_DECISION = 0 

467 MUST_RIICHI = 1 

468 MUST_DAMATEN = 2 

469 

470 # danger modifier 

471 NO_RISK_DANGER_MODIFIER = -3 

472 MODERATE_DANGER_MODIFIER = -2 

473 DEFAULT_DANGER_MODIFIER = 0