Coverage for project/game/ai/helpers/defence.py : 97%

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
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 }
14 # honor tiles
15 HONOR_THIRD = {
16 "value": 40,
17 "description": "Third honor tile (early game)",
18 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
250 ATODZUKE_YAKUHAI_HONOR_BONUS_DANGER = {
251 "value": 400,
252 "description": "Bonus danger yakuhai tiles for atodzuke yakuhai hands",
253 }
255 ###############
256 # The following constants don't follow the logic of other constants, so they are not dictionaries
257 ##############
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
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
276 # borders indicating late round
277 ALMOST_LATE_ROUND = 10
278 LATE_ROUND = 12
279 VERY_LATE_ROUND = 15
281 @staticmethod
282 def make_unverified_suji_coeff(value):
283 return {"value": value, "description": "Additional bonus for number of unverified suji"}
285 @staticmethod
286 def is_safe(danger):
287 return danger == TileDanger.IMPOSSIBLE_WAIT or danger == TileDanger.SAFE_AGAINST_THREATENING_HAND
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
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 )
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 )
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 )
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 )
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]
384 return danger_border
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]
392 return danger_border
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))
401 return danger_border
403 @staticmethod
404 def tune_for_round(player, danger_border, shanten):
405 danger_border_dict = None
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
421 if not danger_border_dict:
422 return danger_border
424 return danger_border_dict[danger_border]
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 }
446class TileDangerHandler:
447 """
448 Place to keep information of tile danger level for each player
449 """
451 values: dict
452 weighted_cost: Optional[int]
453 danger_border: dict
454 can_be_used_for_ryanmen: bool
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
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
469 def set_danger(self, player_seat, danger):
470 self.values[player_seat].append(danger)
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 }
479 def get_danger_reasons(self, player_seat):
480 return self.values[player_seat]
482 def get_danger_border(self, player_seat):
483 return self.danger_border[player_seat]
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
490 def get_max_danger(self):
491 return max(self._danger_array)
493 def get_sum_danger(self):
494 return sum(self._danger_array)
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
506 dangers = self._danger_array
508 weighted = 0
509 num_dangers = 0
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
517 assert num_dangers > 0
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
526 weighted *= danger_multiplier
528 return weighted
530 def get_min_danger_border(self):
531 return min(self._borders_array)
533 def clear_danger(self, player_seat):
534 self.values[player_seat] = []
535 self.danger_border[player_seat] = {}
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
542 return True
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 ]
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 ]