Coverage for project/game/player.py : 95%

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
3import utils.decisions_constants as log
4from game.ai.configs.default import BotDefaultConfig
5from game.ai.main import MahjongAI
6from mahjong.constants import CHUN, EAST, HAKU, HATSU, NORTH, SOUTH, WEST
7from mahjong.tile import Tile, TilesConverter
8from utils.decisions_logger import DecisionsLogger, MeldPrint
11class PlayerInterface:
12 table = None
13 discards = None
14 tiles = None
15 melds = None
16 round_step = None
18 # current player seat
19 seat = 0
20 # where is sitting dealer, based on this information we can calculate player wind
21 dealer_seat = 0
22 # it is important to know initial seat for correct players sorting
23 first_seat = 0
25 # position based on scores
26 position = 0
27 scores = None
28 uma = 0
30 name = ""
31 rank = ""
33 logger = None
35 in_riichi: bool = False
36 is_ippatsu: bool = False
37 is_oikake_riichi: bool = False
38 is_oikake_riichi_against_dealer_riichi_threat: bool = False
39 is_riichi_against_open_hand_threat: bool = False
41 def __init__(self, table, seat, dealer_seat):
42 self.table = table
43 self.seat = seat
44 self.dealer_seat = dealer_seat
45 self.logger = DecisionsLogger()
47 self.erase_state()
49 def __str__(self):
50 result = "{0}".format(self.name)
51 if self.scores is not None:
52 result += " ({:,d})".format(int(self.scores))
53 if self.uma:
54 result += " {0}".format(self.uma)
55 else:
56 result += " ({0})".format(self.rank)
57 return result
59 def __repr__(self):
60 return self.__str__()
62 def init_logger(self, logger):
63 self.logger.logger = logger
65 def erase_state(self):
66 self.tiles = []
67 self.discards = []
68 self.melds = []
69 self.in_riichi = False
70 self.is_ippatsu = False
71 self.is_oikake_riichi = False
72 self.is_oikake_riichi_against_dealer_riichi_threat = False
73 self.is_riichi_against_open_hand_threat = False
74 self.position = 0
75 self.scores = None
76 self.uma = 0
77 self.round_step = 0
79 def add_called_meld(self, meld: MeldPrint):
80 # we already added shouminkan as a pon set
81 if meld.type == MeldPrint.SHOUMINKAN:
82 tile_34 = meld.tiles[0] // 4
84 pon_set = [x for x in self.melds if x.type == MeldPrint.PON and (x.tiles[0] // 4) == tile_34]
86 # when we are doing reconnect and we have called shouminkan set
87 # we will not have called pon set in the hand
88 if pon_set:
89 self.melds.remove(pon_set[0])
91 # we need to add tile that we used for open can to the hand
92 if meld.type == MeldPrint.KAN and meld.opened:
93 self.tiles.append(meld.called_tile)
95 self.melds.append(meld)
97 def add_discarded_tile(self, tile: Tile):
98 self.discards.append(tile)
100 # all tiles that were discarded after player riichi will be safe against him
101 # because of furiten
102 tile = tile.value // 4
103 for player in self.table.players[1:]:
104 if player.in_riichi and tile not in player.safe_tiles:
105 player.safe_tiles.append(tile)
107 # one discard == one round step
108 self.round_step += 1
110 @property
111 def player_wind(self):
112 shift = self.dealer_seat - self.seat
113 position = [0, 1, 2, 3][shift]
114 if position == 0:
115 return EAST
116 elif position == 1:
117 return NORTH
118 elif position == 2:
119 return WEST
120 else:
121 return SOUTH
123 @property
124 def is_dealer(self):
125 return self.seat == self.dealer_seat
127 @property
128 def is_open_hand(self):
129 opened_melds = [x for x in self.melds if x.opened]
130 return len(opened_melds) > 0
132 @property
133 def meld_tiles(self):
134 """
135 Array of 136 tiles format
136 :return:
137 """
138 result = []
139 for meld in self.melds:
140 result.extend(meld.tiles)
141 return result
143 @property
144 def meld_34_tiles(self):
145 """
146 Array of array with 34 tiles indices
147 :return: array
148 """
149 melds = [x.tiles[:] for x in self.melds]
150 results = []
151 for meld in melds:
152 meld_34 = [meld[0] // 4, meld[1] // 4, meld[2] // 4]
153 # kan
154 if len(meld) > 3:
155 meld_34.append(meld[3] // 4)
156 results.append(meld_34)
157 return results
159 @property
160 def valued_honors(self):
161 return [CHUN, HAKU, HATSU, self.table.round_wind_tile, self.player_wind]
164class Player(PlayerInterface):
165 ai: Optional[MahjongAI] = None
166 config: Optional[BotDefaultConfig] = None
167 last_draw = None
168 in_tempai = False
170 def __init__(self, table, seat, dealer_seat, bot_config: Optional[BotDefaultConfig]):
171 super().__init__(table, seat, dealer_seat)
172 self.config = bot_config or BotDefaultConfig()
173 self.ai = MahjongAI(self)
175 def erase_state(self):
176 super().erase_state()
178 self.last_draw = None
179 self.in_tempai = False
181 if self.ai:
182 self.ai.erase_state()
184 def init_hand(self, tiles):
185 self.tiles = tiles
187 self.ai.init_hand()
189 def draw_tile(self, tile_136):
190 context = [
191 f"Step: {self.round_step}",
192 f"Remaining tiles: {self.table.count_of_remaining_tiles}",
193 f"Hand: {self.format_hand_for_print(tile_136)}",
194 ]
195 if self.ai.open_hand_handler.current_strategy:
196 context.append(f"Current strategy: {self.ai.open_hand_handler.current_strategy}")
198 self.logger.debug(log.DRAW, context=context)
200 # it is important to recalculate all threats here
201 self.ai.defence.erase_threats_cache()
203 self.last_draw = tile_136
204 self.tiles.append(tile_136)
206 # we need sort it to have a better string presentation
207 self.tiles = sorted(self.tiles)
209 self.ai.draw_tile(tile_136)
211 def discard_tile(self, discard_tile=None, force_tsumogiri=False):
212 if force_tsumogiri:
213 tile_to_discard = discard_tile
214 with_riichi = False
215 else:
216 tile_to_discard, with_riichi = self.ai.discard_tile(discard_tile)
218 is_tsumogiri = tile_to_discard == self.last_draw
219 # it is important to use table method,
220 # to recalculate revealed tiles and etc.
221 self.table.add_discarded_tile(0, tile_to_discard, is_tsumogiri)
222 self.tiles.remove(tile_to_discard)
224 return tile_to_discard, with_riichi
226 def should_call_kan(self, tile, open_kan, from_riichi=False):
227 self.ai.defence.erase_threats_cache()
228 return self.ai.kan.should_call_kan(tile, open_kan, from_riichi)
230 def should_call_win(self, tile, is_tsumo, enemy_seat=None, is_chankan=False):
231 self.ai.defence.erase_threats_cache()
232 return self.ai.should_call_win(tile, is_tsumo, enemy_seat, is_chankan)
234 def should_call_kyuushu_kyuuhai(self):
235 self.ai.defence.erase_threats_cache()
236 return self.ai.should_call_kyuushu_kyuuhai()
238 def try_to_call_meld(self, tile, is_kamicha_discard):
239 self.ai.defence.erase_threats_cache()
240 return self.ai.try_to_call_meld(tile, is_kamicha_discard)
242 def enemy_called_riichi(self, player_seat):
243 self.ai.enemy_called_riichi(player_seat)
245 def number_of_revealed_tiles(self, tile_34, closed_hand_34):
246 """
247 Return sum of all tiles (discarded + from melds + our hand)
248 :param tile_34: 34 tile format
249 :param closed_hand_34: cached list of tiles (to not build it for each iteration)
250 :return: int
251 """
252 revealed_tiles = closed_hand_34[tile_34] + self.table.revealed_tiles[tile_34]
253 assert revealed_tiles <= 4, "we have only 4 tiles in the game"
254 return revealed_tiles
256 def format_hand_for_print(self, tile_136=None):
257 hand_string = "{}".format(
258 TilesConverter.to_one_line_string(self.closed_hand, print_aka_dora=self.table.has_aka_dora)
259 )
261 if tile_136 is not None:
262 hand_string += " + {}".format(
263 TilesConverter.to_one_line_string([tile_136], print_aka_dora=self.table.has_aka_dora)
264 )
266 melds = []
267 for item in self.melds:
268 melds.append(
269 "{}".format(TilesConverter.to_one_line_string(item.tiles, print_aka_dora=self.table.has_aka_dora))
270 )
272 if melds:
273 hand_string += " [{}]".format(", ".join(melds))
275 return hand_string
277 # FIXME: remove this method and cleanup tests
278 def formal_riichi_conditions(self):
279 return all(
280 [
281 self.in_tempai,
282 not self.in_riichi,
283 not self.is_open_hand,
284 self.scores >= 1000,
285 self.table.count_of_remaining_tiles > 4,
286 ]
287 )
289 @property
290 def closed_hand(self):
291 tiles = self.tiles[:]
292 return [item for item in tiles if item not in self.meld_tiles]
295class EnemyPlayer(PlayerInterface):
296 # array of tiles in 34 tile format
297 safe_tiles = None
298 # tiles that were discarded in the current "step"
299 # so, for example kamicha discard will be a safe tile for all players
300 temporary_safe_tiles = None
302 riichi_tile_136 = None
304 def erase_state(self):
305 super().erase_state()
307 self.safe_tiles = []
308 self.temporary_safe_tiles = []
309 self.riichi_tile_136 = None
311 def add_discarded_tile(self, tile: Tile):
312 super().add_discarded_tile(tile)
314 tile = tile.value // 4
315 if tile not in self.safe_tiles:
316 self.safe_tiles.append(tile)
318 # erase temporary furiten after tile draw
319 self.temporary_safe_tiles = []
320 affected_players = [1, 2, 3]
321 affected_players.remove(self.seat)
323 # temporary furiten, for one "step"
324 for x in affected_players:
325 if tile not in self.table.get_player(x).temporary_safe_tiles:
326 self.table.get_player(x).temporary_safe_tiles.append(tile)
328 @property
329 def all_safe_tiles(self):
330 return list(set(self.temporary_safe_tiles + self.safe_tiles))