diff --git a/bot/bot.py b/bot/bot.py index f4e8d17..c6777b2 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -2,6 +2,7 @@ implement wrappers for supportting different bot types """ import json +import time from abc import ABC, abstractmethod from common.log_helper import LOGGER @@ -9,36 +10,38 @@ from common.utils import GameMode, BotNotSupportingMode -def reaction_convert_meta(reaction:dict, is_3p:bool=False): +def reaction_convert_meta(reaction: dict, is_3p: bool = False): """ add meta_options to reaction """ if 'meta' in reaction: meta = reaction['meta'] reaction['meta_options'] = meta_to_options(meta, is_3p) + class Bot(ABC): """ Bot Interface class bot follows mjai protocol ref: https://mjai.app/docs/highlevel-api - Note: Reach msg has additional 'reach_dahai' key attached, - which is a 'dahai' msg, representing the subsequent dahai action after reach """ - def __init__(self, name:str="Bot") -> None: + def __init__(self, name: str = "Bot") -> None: self.name = name - self._initialized:bool = False - self.seat:int = None - + self._initialized: bool = False + self.seat: int = None + self.mode = None + self.ignore_next_turn_self_reach: bool = False + self.reach_dahai:dict = None + @property def supported_modes(self) -> list[GameMode]: """ return suported game modes""" return [GameMode.MJ4P] - + @property def info_str(self) -> str: """ return description info""" return self.name - def init_bot(self, seat:int, mode:GameMode=GameMode.MJ4P): + def init_bot(self, seat: int, mode: GameMode = GameMode.MJ4P): """ Initialize the bot before the game starts. Bot must be initialized before a new game params: seat(int): Player seat index @@ -46,6 +49,7 @@ def init_bot(self, seat:int, mode:GameMode=GameMode.MJ4P): if mode not in self.supported_modes: raise BotNotSupportingMode(mode) self.seat = seat + self.mode = mode self._init_bot_impl(mode) self._initialized = True @@ -53,18 +57,18 @@ def init_bot(self, seat:int, mode:GameMode=GameMode.MJ4P): def initialized(self) -> bool: """ return True if bot is initialized""" return self._initialized - + @abstractmethod - def _init_bot_impl(self, mode:GameMode=GameMode.MJ4P): + def _init_bot_impl(self, mode: GameMode = GameMode.MJ4P): """ Initialize the bot before the game starts.""" @abstractmethod - def react(self, input_msg:dict) -> dict | None: + def react(self, input_msg: dict) -> dict | None: """ input mjai msg and get bot output if any, or None if not""" - def react_batch(self, input_list:list[dict]) -> dict | None: + def react_batch(self, input_list: list[dict]) -> dict | None: """ input list of mjai msg and get the last output, if any""" - + # default implementation is to iterate and feed to bot if len(input_list) == 0: return None @@ -73,32 +77,45 @@ def react_batch(self, input_list:list[dict]) -> dict | None: self.react(msg) last_reaction = self.react(input_list[-1]) return last_reaction - + def log_game_result(self, mode_id: int, rank: int, score: int): """ log game results""" return + def get_reach_dahai(self) -> dict: + """ + get the reach_dahai message + Only call this method when it is reachable. + """ + self.generate_reach_dahai() + return self.reach_dahai + + + def generate_reach_dahai(self): + reach_msg = {'type': MjaiType.REACH, 'actor': self.seat} + reach_dahai_from_originalbot = self.react(reach_msg) + self.reach_dahai = reach_dahai_from_originalbot + LOGGER.debug(f"Generated and saved reach_dahai: {self.reach_dahai}") + self.ignore_next_turn_self_reach = True + class BotMjai(Bot): """ base class for libriichi.mjai Bots""" - def __init__(self, name:str) -> None: + + def __init__(self, name: str) -> None: super().__init__(name) - + self.mjai_bot = None - self.ignore_next_turn_self_reach:bool = False - - + @property def info_str(self) -> str: return f"{self.name}: [{','.join([m.value for m in self.supported_modes])}]" - - - def _get_engine(self, mode:GameMode): + + def _get_engine(self, mode: GameMode): # return MortalEngine object raise NotImplementedError("Subclass must implement this method") - - - def _init_bot_impl(self, mode:GameMode=GameMode.MJ4P): + + def _init_bot_impl(self, mode: GameMode = GameMode.MJ4P): engine = self._get_engine(mode) if not engine: raise BotNotSupportingMode(mode) @@ -112,35 +129,23 @@ def _init_bot_impl(self, mode:GameMode=GameMode.MJ4P): import libriichi3p self.mjai_bot = libriichi3p.mjai.Bot(engine, self.seat) else: - raise BotNotSupportingMode(mode) - - - def react(self, input_msg:dict) -> dict: + raise BotNotSupportingMode(mode) + + def react(self, input_msg: dict) -> dict: + msg_type = input_msg['type'] if self.mjai_bot is None: - return None - if self.ignore_next_turn_self_reach: # ignore repetitive self reach. only for the very next msg - if input_msg['type'] == MjaiType.REACH and input_msg['actor'] == self.seat: - LOGGER.debug("Ignoring repetitive self reach msg, reach msg already sent to AI last turn") + return None + if self.ignore_next_turn_self_reach == True: + if msg_type == MjaiType.REACH and input_msg['actor'] == self.seat: + LOGGER.debug("Ignoring Reach msg, already fed reach msg to the bot.") return None self.ignore_next_turn_self_reach = False - + + str_input = json.dumps(input_msg) react_str = self.mjai_bot.react(str_input) if react_str is None: return None reaction = json.loads(react_str) - # Special treatment for self reach output msg - # mjai only outputs dahai msg after the reach msg - if reaction['type'] == MjaiType.REACH and reaction['actor'] == self.seat: # Self reach - # get the subsequent dahai message, - # appeding it to the reach reaction msg as 'reach_dahai' key - LOGGER.debug("Send reach msg to get reach_dahai. Cannot go back to unreach!") - # TODO make a clone of mjai_bot so reach can be tested to get dahai without affecting the game - - reach_msg = {'type': MjaiType.REACH, 'actor': self.seat} - reach_dahai_str = self.mjai_bot.react(json.dumps(reach_msg)) - reach_dahai = json.loads(reach_dahai_str) - reaction['reach_dahai'] = reach_dahai - self.ignore_next_turn_self_reach = True # ignore very next reach msg return reaction diff --git a/bot/local/bot_local.py b/bot/local/bot_local.py index d522b54..a767da9 100644 --- a/bot/local/bot_local.py +++ b/bot/local/bot_local.py @@ -15,7 +15,7 @@ def __init__(self, model_files:dict[GameMode, str]) -> None: """ params: model_files(dicty): model files for different modes {mode, file_path} """ - super().__init__("Local Mortal Bot") + super().__init__("Local Mortal Bot") self._supported_modes: list[GameMode] = [] self.model_files = model_files self._engines:dict[GameMode, any] = {} diff --git a/bot/mjapi/bot_mjapi.py b/bot/mjapi/bot_mjapi.py index feaafb4..a90844a 100644 --- a/bot/mjapi/bot_mjapi.py +++ b/bot/mjapi/bot_mjapi.py @@ -82,14 +82,6 @@ def _process_reaction(self, reaction, recurse): else: return None - # process self reach - if recurse and reaction['type'] == MjaiType.REACH and reaction['actor'] == self.seat: - LOGGER.debug("Send reach msg to get reach_dahai.") - reach_msg = {'type': MjaiType.REACH, 'actor': self.seat} - reach_dahai = self.react(reach_msg, recurse=False) - reaction['reach_dahai'] = self._process_reaction(reach_dahai, False) - self.ignore_next_turn_self_reach = True - return reaction def react(self, input_msg:dict, recurse=True) -> dict | None: diff --git a/bot_manager.py b/bot_manager.py index 19821ee..93d8350 100644 --- a/bot_manager.py +++ b/bot_manager.py @@ -547,10 +547,8 @@ def get_tile_str(mjai_tile:str): # unicode + language specific name elif re_type == MjaiType.ANKAN: tile_str = get_tile_str(reaction['consumed'][1]) action_str = f"{ActionUnicode.KAN}{lan_str.KAN}{tile_str}({lan_str.ANKAN})" - elif re_type == MjaiType.REACH: # attach reach dahai options - reach_dahai_reaction = reaction['reach_dahai'] - dahai_action_str, _dahai_options = mjai_reaction_2_guide(reach_dahai_reaction, 0, lan_str) - action_str = f"{ActionUnicode.REACH}{lan_str.RIICHI}," + dahai_action_str + elif re_type == MjaiType.REACH: + action_str = f"{ActionUnicode.REACH}{lan_str.RIICHI}" elif re_type == MjaiType.HORA: if reaction['actor'] == reaction['target']: action_str = f"{ActionUnicode.AGARI}{lan_str.AGARI}({lan_str.TSUMO})" diff --git a/common/mj_helper.py b/common/mj_helper.py index dd55502..8fd85b5 100644 --- a/common/mj_helper.py +++ b/common/mj_helper.py @@ -261,6 +261,137 @@ def decode_mjai_tehai(tehai34, akas, tsumohai) -> tuple[list[str], str]: return (tile_list, tsumohai) +def normalize_pai(pai): + """Turn red dora to normal pai""" + return pai.replace('r', '') + + +def determine_kan_type(last_action, hand): + """ + Determines the type of kan based on the last action taken and the player's hand. + + Description: + This function assumes it is called only when a kan is possible and + distinguishes which type of kan it is: Ankan, Kakan, or Daiminkan. + It first normalizes all tiles in the hand, converting red dora tiles + to their regular counterparts for uniformity in processing. + + Parameters: + last_action (dict): Information about the last action taken, including the type of action + ('tsumo' for a self-draw or 'dahai' for a discard), + the actor's identifier, and the tile involved. + Example: {'type': 'tsumo', 'actor': 0, 'pai': '5mr'} + hand (list of str): List of tiles in the player's hand, excluding the tile drawn most recently, + which is added during processing if necessary. + Example: ['1m', '1m', '1m', '2p', '2p', '2p', '3s', '3s', '3s', + '7m', '7m', '7m', '4p', '5m', '5m', '5m'] + + Returns: + MjaiType: ANKAN, KAKAN, or DAIMINKAN. + The function will return the appropriate enum value based on the conditions met. + + """ + normalized_hand = [normalize_pai(p) for p in hand] + pai = normalize_pai(last_action['pai']) + + if last_action['type'] == 'tsumo': + normalized_hand.append(pai) # Include the drawn tile in the hand + + pai_counts = {p: normalized_hand.count(p) for p in set(normalized_hand)} + + if pai_counts[pai] == 4: + if last_action['type'] == 'tsumo': + return MjaiType.ANKAN + else: + return MjaiType.KAKAN + elif last_action['type'] == 'dahai': + return MjaiType.DAIMINKAN + + +def generate_chi_consume_sequence(tile, chi_type): + """Generate the other two tiles in a sequence based on the type of chi and a given tile.""" + base_number = int(tile[0]) + suit = tile[1] + + # Calculate the other two tiles in the sequence based on the chi_type + if chi_type == 'chi_low': + return [str(base_number + 1) + suit, str(base_number + 2) + suit] + elif chi_type == 'chi_mid': + return [str(base_number - 1) + suit, str(base_number + 1) + suit] + elif chi_type == 'chi_high': + return [str(base_number - 2) + suit, str(base_number - 1) + suit] + else: + raise ValueError("Invalid chi type") + + +def determine_chi_tiles(chi_type, called_tile, hand): + """ + Determine the tiles used for a chi action based on the type of chi and the tile that was called. + + Parameters: + chi_type (str): 'chi_low', 'chi_mid', or 'chi_high', representing the position of the called tile in the sequence. + called_tile (str): The tile that was called, formatted as '5m'. + hand (list of str): Current list of tiles in hand. + + Returns: + list of str: Returns a list of two tiles needed to complete the chow, defaulting to not choosing red + dora tiles when multiple combinations are possible. + """ + sequence_tiles = generate_chi_consume_sequence(called_tile, chi_type) + + # Check if each tile in the sequence is in the hand, considering red dora tiles + needed_tiles = [] + for tile in sequence_tiles: + if tile in hand: + needed_tiles.append(tile) + else: + # Check if the normalized tile (in case of red dora tiles) is in the hand + normalized_tile = normalize_pai(tile) + for hand_tile in hand: + if normalize_pai(hand_tile) == normalized_tile: + needed_tiles.append(hand_tile) + break + # Switch needed_tiles to red dora tile if possible + needed_tiles = [f"{tile}r" if f"{tile}r" in hand else tile for tile in needed_tiles] + + if len(needed_tiles) == len(sequence_tiles): + return needed_tiles + else: + return [] + +def determine_pon_tiles(called_tile, hand): + if called_tile[0] != 5: + return [called_tile] * 2 + else: + pon_tiles_count = 0 + exists_dora = False + tiles_list = [] + for tile in hand: + if tile[0:1] == called_tile[0:1]: + pon_tiles_count += 1 + if tile[2] == 'r': + exists_dora = True + tiles_list.append(tile) + if pon_tiles_count == 3 and exists_dora: + return [called_tile] * 2 + else: + return tiles_list + + +def determine_kan_tiles(called_tile): + if called_tile[0] != 5: + return [called_tile] * 4 + else: + tiles_list = [] + if called_tile[2] == 'r': + tiles_list = [called_tile[0:1]] * 3 + tiles_list.append(called_tile) + else: + tiles_list = [called_tile[0:1]] * 3 + tiles_list.append(f"{called_tile}r") + return tiles_list + + @dataclass class GameInfo: """ data class containing game info""" diff --git a/game/automation.py b/game/automation.py index 1646498..866a1d8 100644 --- a/game/automation.py +++ b/game/automation.py @@ -12,8 +12,8 @@ import threading from typing import Iterable, Iterator -from common.mj_helper import MjaiType, MSType, MJAI_TILES_19, MJAI_TILES_28, MJAI_TILES_SORTED -from common.mj_helper import sort_mjai_tiles, cvt_ms2mjai +from common.mj_helper import MjaiType, MSType, MJAI_TILES_19, MJAI_TILES_28, MJAI_TILES_SORTED, determine_kan_tiles +from common.mj_helper import sort_mjai_tiles, cvt_ms2mjai, determine_kan_type, determine_chi_tiles, determine_pon_tiles from common.log_helper import LOGGER from common.settings import Settings from common.utils import UiState, GAME_MODES @@ -377,6 +377,9 @@ def automate_action(self, mjai_action:dict, game_state:GameState) -> bool: game_state(GameState): game state object Returns: bool: True means automation kicks off. False means not automating.""" + + LOGGER.debug(f'Automating action: {mjai_action}') + if not self.can_automate(): return False if game_state is None or mjai_action is None: @@ -386,11 +389,12 @@ def automate_action(self, mjai_action:dict, game_state:GameState) -> bool: gi = game_state.get_game_info() assert gi is not None, "Game info is None" op_step = game_state.last_op_step - mjai_type = mjai_action['type'] - + mjai_type = mjai_action['type'] + if self.st.ai_randomize_choice: # randomize choice - mjai_action = self.randomize_action(mjai_action, gi) - # Dahai action + mjai_action = self.randomize_action(mjai_action, gi, game_state) + mjai_type = mjai_action['type'] + # Dahai action if mjai_type == MjaiType.DAHAI: if gi.self_reached: # already in reach state. no need to automate dahai @@ -403,7 +407,7 @@ def automate_action(self, mjai_action:dict, game_state:GameState) -> bool: elif mjai_type in [MjaiType.NONE, MjaiType.CHI, MjaiType.PON, MjaiType.DAIMINKAN, MjaiType.ANKAN, MjaiType.KAKAN, MjaiType.HORA, MjaiType.REACH, MjaiType.RYUKYOKU, MjaiType.NUKIDORA]: liqi_operation = game_state.last_operation - more_steps:list[ActionStep] = self.steps_button_action(mjai_action, gi, liqi_operation) + more_steps:list[ActionStep] = self.steps_button_action(mjai_action, gi, liqi_operation, game_state) else: LOGGER.error("No automation for unrecognized mjai type: %s", mjai_type) @@ -423,59 +427,136 @@ def automate_action(self, mjai_action:dict, game_state:GameState) -> bool: self._task.start_action_steps(action_steps, game_state) return True - def randomize_action(self, action:dict, gi:GameInfo) -> dict: - """ Randomize ai choice: pick according to probaility from top 3 options""" + def randomize_action(self, action:dict, gi:GameInfo, game_state:GameState) -> dict: + """ Randomize ai choice: pick according to probaility from at most top 3 options""" n = self.st.ai_randomize_choice # randomize strength. 0 = no random, 5 = according to probability if n == 0: return action + if len(action['meta_options']) == 0: + return action # no options to randomize mjai_type = action['type'] - if mjai_type == MjaiType.DAHAI: - orig_pai = action['pai'] - options:dict = action['meta_options'] # e.g. {'1m':0.95, 'P':0.045, 'N':0.005, ...} - # get dahai options (tile only) from top 3 - top_ops:list = [(k,v) for k,v in options[:3] if k in MJAI_TILES_SORTED] - #pick from top3 according to probability - power = 1 / (0.2 * n) - sum_probs = sum([v**power for k,v in top_ops]) - top_ops_powered = [(k, v**power/sum_probs) for k,v in top_ops] - - # 1. Calculate cumulative probabilities - cumulative_probs = [top_ops_powered[0][1]] - for i in range(1, len(top_ops_powered)): - cumulative_probs.append(cumulative_probs[-1] + top_ops_powered[i][1]) - - # 2. Pick an option based on a random number - rand_prob = random.random() # Random float: 0.0 <= x < 1.0 - chosen_pai = orig_pai # Default in case no option is selected, for safety - prob = top_ops_powered[0][1] - for i, cum_prob in enumerate(cumulative_probs): - if rand_prob < cum_prob: - chosen_pai = top_ops_powered[i][0] # This is the selected key based on probability - prob = top_ops_powered[i][1] # the probability - orig_prob = top_ops[i][1] - break - - if chosen_pai == orig_pai: # return original action if no change - change_str = f"{action['pai']} Unchanged" - else: - change_str = f"{action['pai']} -> {chosen_pai}" - - # generate new action for changed tile - tsumogiri = chosen_pai == gi.my_tsumohai + options: dict = action['meta_options'] + # get options (tile only) from top 3 at most + top_ops = sorted(options, key=lambda item: item[1], reverse=True)[:3] + + # pick from top3 according to probability + power = 1 / (0.2 * n) + sum_probs = sum([v ** power for k, v in top_ops]) + top_ops_powered = [(k, v ** power / sum_probs) for k, v in top_ops] + + # 1. Calculate cumulative probabilities + cumulative_probs = [top_ops_powered[0][1]] + for i in range(1, len(top_ops_powered)): + cumulative_probs.append(cumulative_probs[-1] + top_ops_powered[i][1]) + + # 2. Pick an option based on a random number + rand_prob = random.random() # Random float: 0.0 <= x < 1.0 + chosen_option, prob, orig_prob = None, None, None + for i, cum_prob in enumerate(cumulative_probs): + if rand_prob < cum_prob: + chosen_option = top_ops_powered[i][0] # This is the selected key based on probability + prob = top_ops_powered[i][1] # the probability + orig_prob = top_ops[i][1] + break + + # 3. Check if the chosen option is the highest probability option + if chosen_option == top_ops[0][0]: # Compare with the original top option + LOGGER.debug("Chosen option is the highest probability option, returning original action.") + return action + + LOGGER.debug(f"Randomized Option, Chosen: {chosen_option}, Prob: {prob:.2f}, " + f"Orig Prob: {orig_prob:.2f}, Orig Option: {top_ops[0][0]}") + + new_action = self.construct_new_action(chosen_option, gi, game_state, action) + return new_action + + def construct_new_action(self, chosen_option:str, gi:GameInfo, game_state:GameState, action:dict): + new_action = None + if chosen_option in MJAI_TILES_SORTED: new_action = { 'type': MjaiType.DAHAI, - 'actor': action['actor'], - 'pai': chosen_pai, - 'tsumogiri': tsumogiri + 'actor': gi.self_seat, + 'pai': chosen_option, + 'tsumogiri': chosen_option == gi.my_tsumohai + } + elif chosen_option in ['chi_low', 'chi_mid', 'chi_high']: + called_tile = game_state.last_action['pai'] + hand = game_state.get_game_info().my_tehai + chi_tiles = determine_chi_tiles(chosen_option, called_tile, hand) + target = game_state.last_action['actor'] + consumed = chi_tiles + new_action = { + 'type': MjaiType.CHI, + 'actor': gi.self_seat, + 'target': target, + 'pai': called_tile, + 'consumed': consumed + } + elif chosen_option == 'pon': + target = game_state.last_action['actor'] + consumed = determine_pon_tiles(game_state.last_action['pai'], game_state.get_game_info().my_tehai) + new_action = { + 'type': MjaiType.PON, + 'actor': gi.self_seat, + 'target': target, + 'pai': game_state.last_action['pai'], + 'consumed': consumed + } + elif chosen_option == 'kan_select': + last_action = game_state.last_action + hand = game_state.get_game_info().my_tehai + kan_type = determine_kan_type(last_action, hand) + target = last_action['actor'] + consumed = determine_kan_tiles(last_action['pai']) + if kan_type == MjaiType.DAIMINKAN: + new_action = { + 'type': MjaiType.DAIMINKAN, + 'actor': gi.self_seat, + 'target': target, + 'pai': last_action['pai'], + 'consumed': consumed + } + elif kan_type == MjaiType.ANKAN or MjaiType.KAKAN: + new_action = { + 'type': kan_type, + 'actor': gi.self_seat, + 'pai': last_action['pai'], + 'consumed': consumed + } + elif chosen_option == 'hora': + target = game_state.last_action['actor'] + new_action = { + 'type': MjaiType.HORA, + 'actor': gi.self_seat, + 'target': target + } + elif chosen_option == 'ryukyoku': + target = game_state.last_action['actor'] + new_action = { + 'type': MjaiType.RYUKYOKU, + 'actor': gi.self_seat, + 'target': target + } + elif chosen_option == 'none': + new_action = { + 'type': MjaiType.NONE + } + elif chosen_option == 'nukidora': + new_action = { + 'type': MjaiType.NUKIDORA, + 'actor': gi.self_seat + } + elif chosen_option == 'reach': + new_action = { + 'type': MjaiType.REACH, + 'actor': gi.self_seat } - msg = f"Randomized dahai: {change_str} ([{n}] {orig_prob*100:.1f}% -> {prob*100:.1f}%)" - LOGGER.debug(msg) - return new_action - # other MJAI types else: - return action + LOGGER.error(f"Unknown chosen option: {chosen_option}") + LOGGER.debug(f"Constructed new action: {new_action}") + return new_action + - def last_exec_time(self) -> float: """ return the time of last action execution. return -1 if N/A""" if self._task: @@ -603,7 +684,8 @@ def _process_oplist_for_kan(self, mstype_from_mjai, op_list:list) -> list: return op_list - def steps_button_action(self, mjai_action:dict, gi:GameInfo, liqi_operation:dict) -> list[ActionStep]: + def steps_button_action(self, mjai_action:dict, gi:GameInfo, liqi_operation:dict, + game_state:GameState) -> list[ActionStep]: """Generate action steps for button actions (chi, pon, kan, etc.)""" if 'operationList' not in liqi_operation: # no liqi operations provided - no buttons to click return [] @@ -633,8 +715,8 @@ def steps_button_action(self, mjai_action:dict, gi:GameInfo, liqi_operation:dict return steps # Reach: process subsequent reach dahai action - if mstype_from_mjai == MSType.reach: - reach_dahai = mjai_action['reach_dahai'] + if mstype_from_mjai == MSType.reach: + reach_dahai = game_state.mjai_bot.get_reach_dahai() delay = self.get_delay(reach_dahai, gi) steps.append(ActionStepDelay(delay)) dahai_steps = self.steps_action_dahai(reach_dahai, gi) diff --git a/game/game_state.py b/game/game_state.py index 7d2e86a..00b4c2b 100644 --- a/game/game_state.py +++ b/game/game_state.py @@ -69,6 +69,7 @@ def __init__(self, bot:Bot) -> None: #1-2-3 then goes counter-clockwise self.player_scores:list = None # player scores self.kyoku_state:KyokuState = KyokuState() # kyoku info - cleared every newround + self.last_action:dict = None # last action (dahai, reach, etc.) ### about last reaction self.last_reaction:dict = None # last bot output reaction @@ -84,6 +85,11 @@ def __init__(self, bot:Bot) -> None: """ if any new round has started (so game info is available)""" self.is_game_ended:bool = False # if game has ended + def append_action(self, action:dict): + """ Append action to pending input msgs""" + self.mjai_pending_input_msgs.append(action) + self.last_action = action + def get_game_info(self) -> GameInfo: """ Return game info. Return None if N/A""" if self.is_round_started: @@ -261,7 +267,7 @@ def ms_auth_game(self, liqi_data:dict) -> dict: self.seat = seatList.index(self.account_id) self.mjai_bot.init_bot(self.seat, self.game_mode) # Start_game has no effect for mjai bot, omit here - self.mjai_pending_input_msgs.append( + self.append_action( { 'type': MjaiType.START_GAME, 'id': self.seat @@ -329,9 +335,9 @@ def ms_new_round(self, liqi_data:dict) -> dict: 'scores': self.player_scores, 'tehais': tehais_mjai } - self.mjai_pending_input_msgs.append(start_kyoku_msg) + self.append_action(start_kyoku_msg) if tsumo_msg: - self.mjai_pending_input_msgs.append(tsumo_msg) + self.append_action(tsumo_msg) self.is_round_started = True return self._react_all(liqi_data_data) @@ -342,7 +348,7 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: # when there is new action, accept reach, unless it is agari if not liqi_data_name == LiqiAction.Hule: if self.kyoku_state.pending_reach_acc is not None: - self.mjai_pending_input_msgs.append(self.kyoku_state.pending_reach_acc) + self.append_action(self.kyoku_state.pending_reach_acc) self.kyoku_state.pending_reach_acc = None liqi_data_data = liqi_data['data'] @@ -351,7 +357,7 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: # According to mjai.app, in the case of an ankan, the dora event comes first, followed by the tsumo event. if 'doras' in liqi_data_data: if len(liqi_data_data['doras']) > len(self.kyoku_state.doras_ms): - self.mjai_pending_input_msgs.append( + self.append_action( { 'type': MjaiType.DORA, 'dora_marker': mj_helper.cvt_ms2mjai(liqi_data_data['doras'][-1]) @@ -367,7 +373,7 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: else: # my tsumo tile_mjai = mj_helper.cvt_ms2mjai(liqi_data_data['tile']) self.kyoku_state.my_tsumohai = tile_mjai - self.mjai_pending_input_msgs.append( + self.append_action( { 'type': MjaiType.TSUMO, 'actor': actor, @@ -393,19 +399,18 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: self.kyoku_state.self_in_reach = True self.kyoku_state.player_reach[actor] = True - self.mjai_pending_input_msgs.append( - { + reach_action_dict = { 'type': MjaiType.REACH, 'actor': actor } - ) + self.append_action(reach_action_dict) # pending reach accept msg for mjai. this msg will be sent when next liqi action msg is received self.kyoku_state.pending_reach_acc = { 'type': MjaiType.REACH_ACCEPTED, 'actor': actor } - self.mjai_pending_input_msgs.append( + self.append_action( { 'type': MjaiType.DAHAI, 'actor': actor, @@ -439,7 +444,7 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: match liqi_data_data['type']: case ChiPengGang.Chi: assert len(consumed_mjai) == 2 - self.mjai_pending_input_msgs.append( + self.append_action( { 'type': MjaiType.CHI, 'actor': actor, @@ -450,7 +455,7 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: ) case ChiPengGang.Peng: assert len(consumed_mjai) == 2 - self.mjai_pending_input_msgs.append( + self.append_action( { 'type': MjaiType.PON, 'actor': actor, @@ -461,7 +466,7 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: ) case ChiPengGang.Gang: assert len(consumed_mjai) == 3 - self.mjai_pending_input_msgs.append( + self.append_action( { 'type': MjaiType.DAIMINKAN, 'actor': actor, @@ -491,7 +496,7 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: self.kyoku_state.my_tehai.remove(c) self.kyoku_state.my_tehai = mj_helper.sort_mjai_tiles(self.kyoku_state.my_tehai) - self.mjai_pending_input_msgs.append( + self.append_action( { 'type': MjaiType.ANKAN, 'actor': actor, @@ -510,7 +515,7 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: self.kyoku_state.my_tehai.remove(tile_mjai) self.kyoku_state.my_tehai = mj_helper.sort_mjai_tiles(self.kyoku_state.my_tehai) - self.mjai_pending_input_msgs.append( + self.append_action( { 'type': MjaiType.KAKAN, 'actor': actor, @@ -529,7 +534,7 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: self.kyoku_state.my_tehai.remove('N') self.kyoku_state.my_tehai = mj_helper.sort_mjai_tiles(self.kyoku_state.my_tehai) - self.mjai_pending_input_msgs.append( + self.append_action( { 'type': MjaiType.NUKIDORA, 'actor': actor, @@ -562,7 +567,7 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: def ms_end_kyoku(self) -> dict | None: """ End kyoku and get None as reaction""" self.mjai_pending_input_msgs = [] - # self.mjai_pending_input_msgs.append( + # self.append_action( # { # 'type': MJAI_TYPE.END_KYOKU # } @@ -583,7 +588,7 @@ def ms_game_end_results(self, liqi_data:dict) -> dict: except Exception as e: LOGGER.warning("Error finding scores in game results: %s",e, exc_info=True) - # self.mjai_pending_input_msgs.append( + # self.append_action( # { # 'type': MJAI_TYPE.END_GAME # }