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 Optional 

2 

3 

4class TileDanger: 

5 IMPOSSIBLE_WAIT = { 

6 "value": 0, 

7 "description": "Impossible wait", 

8 } 

9 SAFE_AGAINST_THREATENING_HAND = { 

10 "value": 0, 

11 "description": "Tile can't be used by analyzed threat", 

12 } 

13 

14 # honor tiles 

15 HONOR_THIRD = { 

16 "value": 40, 

17 "description": "Third honor tile (early game)", 

18 } 

19 

20 NON_YAKUHAI_HONOR_SECOND_EARLY = { 

21 "value": 60, 

22 "description": "Second non-yakuhai honor (early game)", 

23 } 

24 NON_YAKUHAI_HONOR_SHONPAI_EARLY = { 

25 "value": 120, 

26 "description": "Shonpai non-yakuhai honor (early game)", 

27 } 

28 YAKUHAI_HONOR_SECOND_EARLY = { 

29 "value": 80, 

30 "description": "Second yakuhai honor (early game)", 

31 } 

32 YAKUHAI_HONOR_SHONPAI_EARLY = { 

33 "value": 160, 

34 "description": "Shonpai yakuhai honor (early game)", 

35 } 

36 DOUBLE_YAKUHAI_HONOR_SECOND_EARLY = { 

37 "value": 120, 

38 "description": "Second double-yakuhai honor (early game)", 

39 } 

40 DOUBLE_YAKUHAI_HONOR_SHONPAI_EARLY = { 

41 "value": 240, 

42 "description": "Shonpai double-yakuhai honor (early game)", 

43 } 

44 

45 NON_YAKUHAI_HONOR_SECOND_MID = { 

46 "value": 80, 

47 "description": "Second non-yakuhai honor (mid game)", 

48 } 

49 NON_YAKUHAI_HONOR_SHONPAI_MID = { 

50 "value": 160, 

51 "description": "Shonpai non-yakuhai honor (mid game)", 

52 } 

53 YAKUHAI_HONOR_SECOND_MID = { 

54 "value": 120, 

55 "description": "Second yakuhai honor (mid game)", 

56 } 

57 DOUBLE_YAKUHAI_HONOR_SECOND_MID = { 

58 "value": 200, 

59 "description": "Second double-yakuhai honor (mid game)", 

60 } 

61 YAKUHAI_HONOR_SHONPAI_MID = { 

62 "value": 240, 

63 "description": "Shonpai yakuhai honor (mid game)", 

64 } 

65 DOUBLE_YAKUHAI_HONOR_SHONPAI_MID = { 

66 "value": 480, 

67 "description": "Shonpai double-yakuhai honor (mid game)", 

68 } 

69 

70 NON_YAKUHAI_HONOR_SECOND_LATE = { 

71 "value": 160, 

72 "description": "Second non-yakuhai honor (late game)", 

73 } 

74 NON_YAKUHAI_HONOR_SHONPAI_LATE = { 

75 "value": 240, 

76 "description": "Shonpai non-yakuhai honor (late game)", 

77 } 

78 YAKUHAI_HONOR_SECOND_LATE = { 

79 "value": 200, 

80 "description": "Second yakuhai honor (late game)", 

81 } 

82 DOUBLE_YAKUHAI_HONOR_SECOND_LATE = { 

83 "value": 300, 

84 "description": "Second double-yakuhai honor (late game)", 

85 } 

86 YAKUHAI_HONOR_SHONPAI_LATE = { 

87 "value": 400, 

88 "description": "Shonpai yakuhai honor (late game)", 

89 } 

90 DOUBLE_YAKUHAI_HONOR_SHONPAI_LATE = { 

91 "value": 600, 

92 "description": "Shonpai double-yakuhai honor (late game)", 

93 } 

94 

95 # kabe tiles 

96 NON_SHONPAI_KABE_STRONG = { 

97 "value": 40, 

98 "description": "Non-shonpai strong kabe tile", 

99 } 

100 SHONPAI_KABE_STRONG = { 

101 "value": 200, 

102 "description": "Shonpai strong kabe tile", 

103 } 

104 NON_SHONPAI_KABE_WEAK = { 

105 "value": 80, 

106 "description": "Non-shonpai weak kabe tile", 

107 } 

108 # weak shonpai kabe is actually less suspicious then a strong one 

109 SHONPAI_KABE_WEAK = { 

110 "value": 120, 

111 "description": "Shonpai weak kabe tile", 

112 } 

113 

114 NON_SHONPAI_KABE_STRONG_OPEN_HAND = { 

115 "value": 60, 

116 "description": "Non-shonpai strong kabe tile (against open hand)", 

117 } 

118 SHONPAI_KABE_STRONG_OPEN_HAND = { 

119 "value": 300, 

120 "description": "Shonpai strong kabe tile (against open hand)", 

121 } 

122 NON_SHONPAI_KABE_WEAK_OPEN_HAND = { 

123 "value": 120, 

124 "description": "Non-shonpai weak kabe tile (against open hand)", 

125 } 

126 SHONPAI_KABE_WEAK_OPEN_HAND = { 

127 "value": 200, 

128 "description": "Shonpai weak kabe tile (against open hand)", 

129 } 

130 

131 # suji tiles 

132 SUJI_19_NOT_SHONPAI = { 

133 "value": 40, 

134 "description": "Non-shonpai 1 or 9 with suji", 

135 } 

136 SUJI_19_SHONPAI = { 

137 "value": 80, 

138 "description": "Shonpai 1 or 9 with suji", 

139 } 

140 SUJI = { 

141 "value": 120, 

142 "description": "Default suji", 

143 } 

144 SUJI_28_ON_RIICHI = { 

145 "value": 300, 

146 "description": "Suji on 2 or 8 on riichi declaration", 

147 } 

148 SUJI_37_ON_RIICHI = { 

149 "value": 400, 

150 "description": "Suji on 3 or 7 on riichi declaration", 

151 } 

152 

153 SUJI_19_NOT_SHONPAI_OPEN_HAND = { 

154 "value": 100, 

155 "description": "Non-shonpai 1 or 9 with suji (against open hand)", 

156 } 

157 SUJI_19_SHONPAI_OPEN_HAND = { 

158 "value": 200, 

159 "description": "Shonpai 1 or 9 with suji (against open hand)", 

160 } 

161 SUJI_OPEN_HAND = { 

162 "value": 160, 

163 "description": "Default suji (against open hand)", 

164 } 

165 

166 # possible ryanmen waits 

167 RYANMEN_BASE_SINGLE = { 

168 "value": 300, 

169 "description": "Base danger for possible wait in a single ryanmen", 

170 } 

171 RYANMEN_BASE_DOUBLE = { 

172 "value": 500, 

173 "description": "Base danger for possible wait in two ryanmens", 

174 } 

175 

176 # bonus dangers for possible ryanmen waits 

177 BONUS_MATAGI_SUJI = { 

178 "value": 80, 

179 "description": "Additional danger for matagi-suji pattern", 

180 } 

181 BONUS_AIDAYONKEN = { 

182 "value": 80, 

183 "description": "Additional danger for aidayonken pattern", 

184 } 

185 BONUS_EARLY_5 = { 

186 "value": 80, 

187 "description": "Additional danger for 1 and 9 in case of early 5 discarded in that suit", 

188 } 

189 BONUS_EARLY_28 = { 

190 "value": -80, 

191 "description": "Negative danger for 19 after early 28", 

192 } 

193 BONUS_EARLY_37 = { 

194 "value": -60, 

195 "description": "Negative danger for 1289 after early 37", 

196 } 

197 

198 # doras 

199 DORA_BONUS = { 

200 "value": 200, 

201 "description": "Additional danger for tile being a dora", 

202 } 

203 DORA_CONNECTOR_BONUS = { 

204 "value": 80, 

205 "description": "Additional danger for tile being dora connector", 

206 } 

207 

208 # early discards - these are considered only if ryanmen is possible 

209 NEGATIVE_BONUS_19_EARLY_2378 = { 

210 "value": -80, 

211 "description": "Subtracted danger for 1 or 9 because of early 2, 3, 7 or 8 discard", 

212 } 

213 NEGATIVE_BONUS_28_EARLY_37 = { 

214 "value": -40, 

215 "description": "Subtracted danger for 2 or 8 because of early 3 or 7 discard", 

216 } 

217 

218 # bonus danger for different yaku 

219 # they may add up 

220 HONITSU_THIRD_HONOR_BONUS_DANGER = { 

221 "value": 80, 

222 "description": "Additional danger for third honor against honitsu hands", 

223 } 

224 HONITSU_SECOND_HONOR_BONUS_DANGER = { 

225 "value": 160, 

226 "description": "Additional danger for second honor against honitsu hands", 

227 } 

228 HONITSU_SHONPAI_HONOR_BONUS_DANGER = { 

229 "value": 280, 

230 "description": "Additional danger for shonpai honor against honitsu hands", 

231 } 

232 

233 TOITOI_SECOND_YAKUHAI_HONOR_BONUS_DANGER = { 

234 "value": 120, 

235 "description": "Additional danger for second honor against honitsu hands", 

236 } 

237 TOITOI_SHONPAI_NON_YAKUHAI_BONUS_DANGER = { 

238 "value": 160, 

239 "description": "Additional danger for non-yakuhai shonpai tiles agains toitoi hands", 

240 } 

241 TOITOI_SHONPAI_YAKUHAI_BONUS_DANGER = { 

242 "value": 240, 

243 "description": "Additional danger for shonpai yakuhai against toitoi hands", 

244 } 

245 TOITOI_SHONPAI_DORA_BONUS_DANGER = { 

246 "value": 240, 

247 "description": "Additional danger for shonpai dora tiles agains toitoi hands", 

248 } 

249 

250 ATODZUKE_YAKUHAI_HONOR_BONUS_DANGER = { 

251 "value": 400, 

252 "description": "Bonus danger yakuhai tiles for atodzuke yakuhai hands", 

253 } 

254 

255 ############### 

256 # The following constants don't follow the logic of other constants, so they are not dictionaries 

257 ############## 

258 

259 # count of possible forms 

260 FORM_BONUS_DESCRIPTION = "Forms bonus" 

261 FORM_BONUS_KANCHAN = 3 

262 FORM_BONUS_PENCHAN = 3 

263 FORM_BONUS_SYANPON = 12 

264 FORM_BONUS_TANKI = 12 

265 FORM_BONUS_RYANMEN = 8 

266 

267 # suji counting, (SUJI_COUNT_BOUNDARY - n) * SUJI_COUNT_MODIFIER 

268 # We count how many ryanmen waits are still possible. Maximum n is 18, minimum is 1. 

269 # If there are many possible ryanmens left, we consider situation less dangerous 

270 # than if there are few possible ryanmens left. 

271 # If n is 0, we don't consider this as a factor at all, because that means that wait is not ryanmen. 

272 # Actually that should mean that non-ryanmen waits are now much more dangerous that before. 

273 SUJI_COUNT_BOUNDARY = 10 

274 SUJI_COUNT_MODIFIER = 20 

275 

276 # borders indicating late round 

277 ALMOST_LATE_ROUND = 10 

278 LATE_ROUND = 12 

279 VERY_LATE_ROUND = 15 

280 

281 @staticmethod 

282 def make_unverified_suji_coeff(value): 

283 return {"value": value, "description": "Additional bonus for number of unverified suji"} 

284 

285 @staticmethod 

286 def is_safe(danger): 

287 return danger == TileDanger.IMPOSSIBLE_WAIT or danger == TileDanger.SAFE_AGAINST_THREATENING_HAND 

288 

289 

290class DangerBorder: 

291 IGNORE = 1000000 

292 EXTREME = 1200 

293 VERY_HIGH = 1000 

294 HIGH = 800 

295 UPPER_MEDIUM = 700 

296 MEDIUM = 600 

297 LOWER_MEDIUM = 500 

298 UPPER_LOW = 400 

299 LOW = 300 

300 VERY_LOW = 200 

301 EXTREMELY_LOW = 120 

302 LOWEST = 80 

303 BETAORI = 0 

304 

305 one_step_down_dict = dict( 

306 { 

307 IGNORE: EXTREME, 

308 EXTREME: VERY_HIGH, 

309 VERY_HIGH: HIGH, 

310 HIGH: UPPER_MEDIUM, 

311 UPPER_MEDIUM: MEDIUM, 

312 MEDIUM: LOWER_MEDIUM, 

313 LOWER_MEDIUM: UPPER_LOW, 

314 UPPER_LOW: LOW, 

315 LOW: VERY_LOW, 

316 VERY_LOW: EXTREMELY_LOW, 

317 EXTREMELY_LOW: LOWEST, 

318 LOWEST: BETAORI, 

319 BETAORI: BETAORI, 

320 } 

321 ) 

322 

323 one_step_up_dict = dict( 

324 { 

325 IGNORE: IGNORE, 

326 EXTREME: IGNORE, 

327 VERY_HIGH: EXTREME, 

328 HIGH: VERY_HIGH, 

329 UPPER_MEDIUM: HIGH, 

330 MEDIUM: UPPER_MEDIUM, 

331 LOWER_MEDIUM: MEDIUM, 

332 UPPER_LOW: LOWER_MEDIUM, 

333 LOW: UPPER_LOW, 

334 VERY_LOW: LOW, 

335 EXTREMELY_LOW: VERY_LOW, 

336 LOWEST: EXTREMELY_LOW, 

337 # betaori means betaori, don't tune it up 

338 BETAORI: BETAORI, 

339 } 

340 ) 

341 

342 late_danger_dict = dict( 

343 { 

344 IGNORE: IGNORE, 

345 EXTREME: VERY_HIGH, 

346 VERY_HIGH: HIGH, 

347 HIGH: UPPER_MEDIUM, 

348 UPPER_MEDIUM: MEDIUM, 

349 MEDIUM: LOWER_MEDIUM, 

350 LOWER_MEDIUM: UPPER_LOW, 

351 UPPER_LOW: LOW, 

352 LOW: VERY_LOW, 

353 VERY_LOW: EXTREMELY_LOW, 

354 EXTREMELY_LOW: LOWEST, 

355 LOWEST: BETAORI, 

356 BETAORI: BETAORI, 

357 } 

358 ) 

359 

360 very_late_danger_dict = dict( 

361 { 

362 IGNORE: VERY_HIGH, 

363 EXTREME: HIGH, 

364 VERY_HIGH: UPPER_MEDIUM, 

365 HIGH: MEDIUM, 

366 UPPER_MEDIUM: LOWER_MEDIUM, 

367 MEDIUM: UPPER_LOW, 

368 LOWER_MEDIUM: LOW, 

369 UPPER_LOW: VERY_LOW, 

370 LOW: EXTREMELY_LOW, 

371 VERY_LOW: LOWEST, 

372 EXTREMELY_LOW: BETAORI, 

373 LOWEST: BETAORI, 

374 BETAORI: BETAORI, 

375 } 

376 ) 

377 

378 @staticmethod 

379 def tune_down(danger_border, steps): 

380 assert steps >= 0 

381 for _ in range(steps): 

382 danger_border = DangerBorder.one_step_down_dict[danger_border] 

383 

384 return danger_border 

385 

386 @staticmethod 

387 def tune_up(danger_border, steps): 

388 assert steps >= 0 

389 for _ in range(steps): 

390 danger_border = DangerBorder.one_step_up_dict[danger_border] 

391 

392 return danger_border 

393 

394 @staticmethod 

395 def tune(danger_border, value): 

396 if value > 0: 

397 return DangerBorder.tune_up(danger_border, value) 

398 elif value < 0: 

399 return DangerBorder.tune_down(danger_border, abs(value)) 

400 

401 return danger_border 

402 

403 @staticmethod 

404 def tune_for_round(player, danger_border, shanten): 

405 danger_border_dict = None 

406 

407 if shanten == 0: 

408 if len(player.discards) > TileDanger.LATE_ROUND: 

409 danger_border_dict = DangerBorder.late_danger_dict 

410 if len(player.discards) > TileDanger.VERY_LATE_ROUND: 

411 danger_border_dict = DangerBorder.very_late_danger_dict 

412 elif shanten == 1: 

413 if len(player.discards) > TileDanger.LATE_ROUND: 

414 danger_border_dict = DangerBorder.very_late_danger_dict 

415 elif shanten == 2: 

416 if len(player.discards) > TileDanger.ALMOST_LATE_ROUND: 

417 danger_border_dict = DangerBorder.late_danger_dict 

418 if len(player.discards) > TileDanger.LATE_ROUND: 

419 return DangerBorder.BETAORI 

420 

421 if not danger_border_dict: 

422 return danger_border 

423 

424 return danger_border_dict[danger_border] 

425 

426 

427class EnemyDanger: 

428 THREAT_RIICHI = { 

429 "id": "threatening_riichi", 

430 "description": "Enemy called riichi", 

431 } 

432 THREAT_OPEN_HAND_AND_MULTIPLE_DORA = { 

433 "id": "threatening_open_hand_dora", 

434 "description": "Enemy opened hand with 3+ dora and now is 6+ step", 

435 } 

436 THREAT_EXPENSIVE_OPEN_HAND = { 

437 "id": "threatening_3_han_meld", 

438 "description": "Enemy opened hand has 3+ han", 

439 } 

440 THREAT_OPEN_HAND_UNKNOWN_COST = { 

441 "id": "threatening_melds", 

442 "description": "Enemy opened hand and we are not sure if it's expensive", 

443 } 

444 

445 

446class TileDangerHandler: 

447 """ 

448 Place to keep information of tile danger level for each player 

449 """ 

450 

451 values: dict 

452 weighted_cost: Optional[int] 

453 danger_border: dict 

454 can_be_used_for_ryanmen: bool 

455 

456 # if we estimate that one's threat cost is less than COST_PERCENT_THRESHOLD of other's 

457 # we ignore it when choosing tile for fold 

458 COST_PERCENT_THRESHOLD = 40 

459 

460 def __init__(self): 

461 """ 

462 1, 2, 3 is our opponents seats 

463 """ 

464 self.values = {1: [], 2: [], 3: []} 

465 self.weighted_cost = 0 

466 self.danger_border = {1: {}, 2: {}, 3: {}} 

467 self.can_be_used_for_ryanmen: bool = False 

468 

469 def set_danger(self, player_seat, danger): 

470 self.values[player_seat].append(danger) 

471 

472 def set_danger_border(self, player_seat, danger_border: int, our_hand_cost: int, enemy_hand_cost: int): 

473 self.danger_border[player_seat] = { 

474 "border": danger_border, 

475 "our_hand_cost": our_hand_cost, 

476 "enemy_hand_cost": enemy_hand_cost, 

477 } 

478 

479 def get_danger_reasons(self, player_seat): 

480 return self.values[player_seat] 

481 

482 def get_danger_border(self, player_seat): 

483 return self.danger_border[player_seat] 

484 

485 def get_total_danger_for_player(self, player_seat): 

486 total = sum([x["value"] for x in self.values[player_seat]]) 

487 assert total >= 0 

488 return total 

489 

490 def get_max_danger(self): 

491 return max(self._danger_array) 

492 

493 def get_sum_danger(self): 

494 return sum(self._danger_array) 

495 

496 def get_weighted_danger(self): 

497 costs = [ 

498 self.get_danger_border(1).get("enemy_hand_cost") or 0, 

499 self.get_danger_border(2).get("enemy_hand_cost") or 0, 

500 self.get_danger_border(3).get("enemy_hand_cost") or 0, 

501 ] 

502 max_cost = max(costs) 

503 if max_cost == 0: 

504 return 0 

505 

506 dangers = self._danger_array 

507 

508 weighted = 0 

509 num_dangers = 0 

510 

511 for cost, danger in zip(costs, dangers): 

512 if cost * 100 / max_cost >= self.COST_PERCENT_THRESHOLD: 

513 # divide by 8000 so it's more human-readable 

514 weighted += cost * danger / 8000 

515 num_dangers += 1 

516 

517 assert num_dangers > 0 

518 

519 # this way we balance out tiles that are kinda safe against all the threats 

520 # and tiles that are genbutsu against one threat and are dangerours against the other 

521 if num_dangers == 1: 

522 danger_multiplier = 1 

523 else: 

524 danger_multiplier = 0.8 

525 

526 weighted *= danger_multiplier 

527 

528 return weighted 

529 

530 def get_min_danger_border(self): 

531 return min(self._borders_array) 

532 

533 def clear_danger(self, player_seat): 

534 self.values[player_seat] = [] 

535 self.danger_border[player_seat] = {} 

536 

537 def is_danger_acceptable(self): 

538 for border, danger in zip(self._borders_array, self._danger_array): 

539 if border < danger: 

540 return False 

541 

542 return True 

543 

544 @property 

545 def _danger_array(self): 

546 return [ 

547 self.get_total_danger_for_player(1), 

548 self.get_total_danger_for_player(2), 

549 self.get_total_danger_for_player(3), 

550 ] 

551 

552 @property 

553 def _borders_array(self): 

554 return [ 

555 self.get_danger_border(1).get("border") or 0, 

556 self.get_danger_border(2).get("border") or 0, 

557 self.get_danger_border(3).get("border") or 0, 

558 ]