Coverage for project/game/ai/placement.py : 76%

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
5class PlacementHandler:
6 def __init__(self, player):
7 self.player = player
8 self.table = player.table
10 def get_allowed_danger_modifier(self) -> int:
11 placement = self.get_current_placement()
12 placement_evaluation = self._get_placement_evaluation(placement)
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
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
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
39 return Placement.DEFAULT_DANGER_MODIFIER
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
47 placement = self.get_current_placement()
48 if not placement:
49 return Placement.DEFAULT_RIICHI_DECISION
51 placement_evaluation = self._get_placement_evaluation(placement)
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 }
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
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
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
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
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
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
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
118 return Placement.DEFAULT_RIICHI_DECISION
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
125 # never skip win if we are the dealer
126 if self.player.is_dealer:
127 return True
129 placement = self.get_current_placement()
130 if not placement:
131 return True
133 needed_cost = self.get_minimal_cost_needed(placement=placement)
134 if needed_cost == 0:
135 return True
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]
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"]
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 }
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
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
173 if covered_cost < needed_cost:
174 self.player.logger.debug(log.AGARI, "Decided to skip ron", logger_context)
175 return False
177 self.player.logger.debug(log.AGARI, "Decided to take ron for better placement", logger_context)
178 return True
180 def get_minimal_cost_needed(self, placement=None):
181 if not self.is_oorasu:
182 return 0
184 if not placement:
185 placement = self.get_current_placement()
186 if not placement:
187 return 0
189 if placement["place"] == 4:
190 third_place = self.table.get_players_sorted_by_scores()[2]
192 extra = 0
193 if self.player.first_seat > third_place.first_seat:
194 extra = 100
196 return placement["diff_with_next_up"] + extra
198 return 0
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
205 if not placement:
206 placement = self.get_current_placement()
208 if placement["place"] != 4:
209 return minimal_cost
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
215 if num_players_over_30000 == 1:
216 minimal_cost = min(minimal_cost, self.player.table.get_players_sorted_by_scores()[0].scores - 30000)
218 return minimal_cost
220 def get_current_placement(self):
221 if not self.points_initialized:
222 return None
224 players_by_points = self.table.get_players_sorted_by_scores()
225 current_place = players_by_points.index(self.player)
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 }
238 def _get_placement_evaluation(self, placement) -> int:
239 if not placement:
240 return Placement.NEUTRAL
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
247 if placement["diff_with_2nd"] >= self.comfortable_diff:
248 return Placement.COMFORTABLE_FIRST
250 return Placement.NEUTRAL
252 def must_push(self, threats, tile_136, num_shanten, tempai_cost=0) -> bool:
253 if not self.is_oorasu:
254 return False
256 if not threats:
257 return False
259 placement = self.get_current_placement()
260 if not placement:
261 return False
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 }
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
276 # don't force push with 2 or more shanten if we are not 4th
277 if num_shanten > 1:
278 return False
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
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"]
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
295 # it's not _must_ to push against dealer, let's decide considering other factors
296 if fourth_place.is_dealer:
297 return False
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
317 return False
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
324 if placement["diff_with_3rd"] < self.comfortable_diff:
325 return False
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
341 return False
343 if placement["place"] == 1:
344 second_place = players_by_points[1]
345 if threat.enemy != second_place:
346 return False
348 if placement["diff_with_3rd"] < self.comfortable_diff:
349 return False
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
366 return False
368 # actually should never get here, but let's leave it in case we modify this code
369 return False
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
378 bonus = self.table_bonus_tsumo
380 return base + bonus
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
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
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
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
400 @property
401 def is_oorasu(self):
402 # TODO: consider tonpu
403 return self.table.round_wind_number >= 7
405 @property
406 def is_west_4(self):
407 # TODO: consider tonpu
408 return self.table.round_wind_number == 11
410 @property
411 def is_late_round(self):
412 # TODO: consider tonpu
413 return self.table.round_wind_number >= 6
416class DummyPlacementHandler(PlacementHandler):
417 """
418 Use this class in config if you want to disable placement logic for bot
419 """
421 def get_allowed_danger_modifier(self) -> int:
422 return Placement.DEFAULT_DANGER_MODIFIER
424 def must_riichi(self, has_yaku, num_waits, cost_with_riichi, cost_with_damaten) -> int:
425 return Placement.DEFAULT_RIICHI_DECISION
427 def _get_placement_evaluation(self, placement) -> int:
428 return Placement.NEUTRAL
430 def should_call_win(self, cost, is_tsumo, enemy_seat):
431 return True
433 def get_minimal_cost_needed(self, placement=None) -> int:
434 return 0
436 def get_minimal_cost_needed_considering_west(self, placement=None) -> int:
437 return 0
439 def must_push(self, threats, tile_136, num_shanten, tempai_cost=0) -> bool:
440 return False
442 @property
443 def comfortable_diff(self) -> int:
444 return 0
447class Placement:
448 # TODO: account for honbas and riichi sticks on the table
449 VERY_COMFORTABLE_DIFF = 24100
450 COMFORTABLE_DIFF_FOR_RISK = 18100
452 COMFORTABLE_DIFF_DEALER = 12100
453 COMFORTABLE_DIFF_NON_DEALER = 10100
455 COMFORTABLE_POINTS = 38000
457 RYUKOKU_MINIMUM_DIFF = 4000
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
465 # riichi definitions
466 DEFAULT_RIICHI_DECISION = 0
467 MUST_RIICHI = 1
468 MUST_DAMATEN = 2
470 # danger modifier
471 NO_RISK_DANGER_MODIFIER = -3
472 MODERATE_DANGER_MODIFIER = -2
473 DEFAULT_DANGER_MODIFIER = 0