Ejemplo n.º 1
0
class CombatModule(object):
    def __init__(self, config, stats, regions, fleets):
        """Initializes the Combat module.

        Args:
            config (Config): kcauto-kai Config instance
            stats (Stats): kcauto-kai Stats instance
            regions (dict): dict of pre-defined kcauto-kai regions
            fleets (dict): dict of active CombatFleet instances
        """
        self.enabled = True
        self.disabled_time = None
        self.config = config
        self.stats = stats
        self.regions = regions
        self.kc_region = regions['game']
        self.fast_kc_region = Region(self.kc_region)
        self.fast_kc_region.setAutoWaitTimeout(0)
        self.observeRegion = Region(self.kc_region)
        self.fleets = fleets
        self.next_combat_time = datetime.now()

        self.combined_fleet = self.config.combat['combined_fleet']
        self.striking_fleet = self.config.combat['striking_fleet']

        self.primary_fleet = fleets[3] if self.striking_fleet else fleets[1]
        self.fleet_icon = 'fleet_icon_standard.png'
        if self.combined_fleet:
            self.fleet_icon = 'fleet_icon_{}.png'.format(
                self.config.combat['fleet_mode'])
        self.dmg = {}

        self.map = MapData(self.config.combat['map'], self.regions,
                           self.config)
        self.current_position = [0, 0]
        self.current_node = None
        self.nodes_run = []

        self.lbas = (LBAS(config, regions, self.map)
                     if self.config.combat['lbas_enabled'] else None)

        # combat-related regions
        x = self.kc_region.x
        y = self.kc_region.y
        self.module_regions = {
            'game': self.kc_region,
            'check_fatigue': Region(x + 500, y + 135, 22, 290),
            'check_damage': Region(x + 461, y + 130, 48, 300),
            'check_damage_7th': Region(x + 461, y + 376, 48, 50),
            'check_damage_flagship': Region(x + 290, y + 185, 70, 50),
            'check_damage_combat': Region(x + 290, y + 140, 70, 320),
            'event_next': Region(x + 720, y + 340, 80, 70),
        }

    def goto_combat(self):
        """Method to navigate to the combat menu.
        """
        Nav.goto(self.regions, 'combat')

    def check_need_to_sortie(self):
        """Method to check whether the combat fleets need to sortie based on
        the stored next combat time.

        Returns:
            bool: True if the combat fleets need to sortie, False otherwise
        """
        if not self.enabled:
            return False
        if self.next_combat_time < datetime.now():
            return True
        return False

    def set_next_combat_time(self, delta={}):
        """Method to set the next combat time based on the provided hours,
        minutes, and seconds delta.

        Args:
            delta (dict, optional): dict containing the hours, minutes, and
                seconds delta
        """
        self.next_combat_time = datetime.now() + timedelta(
            hours=delta['hours'] if 'hours' in delta else 0,
            minutes=delta['minutes'] if 'minutes' in delta else 0,
            seconds=delta['seconds'] if 'seconds' in delta else 0)

    def combat_logic_wrapper(self):
        """Method that fires off the necessary child methods that encapsulates
        the entire action of sortieing combat fleets and resolving combat.

        Returns:
            bool: False if the combat fleets could not be sortied
        """
        self.stats.increment_combat_attempted()

        if not self._select_combat_map():
            # LBAS fatigue check failed; cancel sortie
            return False

        if self._conduct_pre_sortie_checks():
            start_button = 'combat_start.png'
            if (self.lbas and (self.config.combat['lbas_group_1_nodes']
                               or self.config.combat['lbas_group_2_nodes']
                               or self.config.combat['lbas_group_3_nodes'])):
                start_button = 'combat_start_lbas.png'
            # attempt to click sortie start button
            if Util.check_and_click(self.regions['lower_right'], start_button):
                Util.log_msg("Beginning combat sortie.")
            else:
                # generic sortie fail catch
                Util.log_warning("Could not begin sortie for some reason!")
                self.set_next_combat_time({'minutes': 5})
                return False
        else:
            # fleet fatigue/damage check failed; cancel sortie
            return False

        # reset FCF retreat counters for combined and striking fleets
        if self.combined_fleet:
            self.fleets[1].reset_fcf_retreat_counts()
            self.fleets[2].reset_fcf_retreat_counts()
        if self.striking_fleet:
            self.fleets[3].reset_fcf_retreat_counts()

        self._run_combat_logic()
        self.set_next_combat_time()

        # after combat, resolve the FCF retreat counters for combined and
        # striking fleets and add them back to their damage counters
        if self.combined_fleet:
            self.fleets[1].resolve_fcf_retreat_counts()
            self.fleets[2].resolve_fcf_retreat_counts()
            self.fleets[2].damage_counts['repair'] = 0
        if self.striking_fleet:
            self.fleets[3].resolve_fcf_retreat_counts()
            self.fleets[3].damage_counts['repair'] = 0
        else:
            self.fleets[1].damage_counts['repair'] = 0

        return True

    def _select_combat_map(self):
        """Method that goes through the menu and chooses the specified map to
        sortie to. LBAS checks are also resolved at this point.

        Returns:
            bool: True if the combat map is successfully chosen and started,
                False if an LBAS check failed
        """
        Util.rejigger_mouse(self.regions, 'top')

        if self.map.world == 'event':
            Util.wait_and_click(self.regions['lower'], '_event_world.png')
        else:
            Util.wait_and_click_and_wait(
                self.regions['lower'], 'c_world_{}.png'.format(self.map.world),
                self.kc_region, 'c_world_{}-1.png'.format(self.map.world))
        Util.rejigger_mouse(self.regions, 'top')

        if self.lbas:
            # resupply and delay sortie time if LBAS fails fatigue check
            lbas_check_fatigue = ('CheckFatigue'
                                  in self.config.combat['misc_options'])
            pass_lbas_check, delay_time = (
                self.lbas.resupply_groups(lbas_check_fatigue))
            if not pass_lbas_check:
                self.set_next_combat_time({'minutes': delay_time})
                return False

        if self.map.world == 'event':
            for page in range(1, int(self.map.subworld[0])):
                Util.check_and_click(self.kc_region,
                                     '_event_next_page_{}.png'.format(page))
                Util.rejigger_mouse(self.regions, 'top')
                Util.kc_sleep(2)
            Util.wait_and_click(
                self.kc_region,
                '_event_world_{}.png'.format(self.map.subworld))
            # dismiss Ooyodo chalkboards
            self.kc_region.wait('event_chalkboard.png', 10)
            while self.kc_region.exists('event_chalkboard'):
                Util.kc_sleep(1)
                Util.click_preset_region(self.regions, 'center')
                Util.kc_sleep(1)
                if self.regions['lower_right'].exists('sortie_select.png'):
                    break
        else:
            if int(self.map.subworld) > 4:
                Util.wait_and_click(self.regions['right'],
                                    'c_world_eo_arrow.png')
                Util.rejigger_mouse(self.regions, 'top')
                Util.kc_sleep(2)
            Util.wait_and_click(
                self.kc_region,
                'c_world_{}-{}.png'.format(self.map.world, self.map.subworld))
        Util.wait_and_click(self.regions['lower_right'], 'sortie_select.png')
        Util.rejigger_mouse(self.regions, 'top')
        return True

    def _conduct_pre_sortie_checks(self):
        """Method to conduct pre-sortie fatigue and supply checks on the
        combat fleets as needed.

        Returns:
            bool: True if the fleet passes the pre-sortie checks, False
                otherwise
        """
        cancel_sortie = False

        if self.config.combat['fleet_mode'] == 'striking':
            # switch fleet to 3rd fleet if striking fleet
            Util.kc_sleep(1)
            Fleet.switch(self.regions['top_submenu'], 3)

        needs_resupply, self.dmg, fleet_fatigue = (
            self._run_pre_sortie_fleet_check_logic(self.primary_fleet))

        if self.combined_fleet:
            # additional combined fleet checks
            Fleet.switch(self.regions['top_submenu'], 2)
            two_needs_resupply, fleet_two_damages, fleet_two_fatigue = (
                self._run_pre_sortie_fleet_check_logic(self.fleets[2]))
            Fleet.switch(self.regions['top_submenu'], 1)

            self.dmg = self._combine_fleet_damages(self.dmg, fleet_two_damages)
            for key in fleet_fatigue:
                fleet_fatigue[key] = (fleet_fatigue[key]
                                      or fleet_two_fatigue[key])

        if needs_resupply:
            Util.log_warning("Canceling combat sortie: resupply required.")
            self.set_next_combat_time()
            cancel_sortie = True

        if 'CheckFatigue' in self.config.combat['misc_options']:
            if fleet_fatigue['high']:
                Util.log_warning(
                    "Canceling combat sortie: fleet has high fatigue.")
                self.set_next_combat_time({'minutes': 25})
                cancel_sortie = True
            elif fleet_fatigue['medium']:
                Util.log_warning(
                    "Canceling combat sortie: fleet has medium fatigue.")
                self.set_next_combat_time({'minutes': 15})
                cancel_sortie = True

        # just use fleet 1's method
        damage_counts_at_threshold = (
            self.primary_fleet.get_damage_counts_at_threshold(
                self.config.combat['repair_limit'], self.dmg))

        if damage_counts_at_threshold > 0:
            Util.log_warning(
                "Canceling combat sortie: {:d} ships above damage threshold.".
                format(damage_counts_at_threshold))
            self.set_next_combat_time()
            cancel_sortie = True

        if ('PortCheck' in self.config.combat['misc_options']
                or self.map.world == 'event'):
            port_full_notice = ('warning_port_full_event.png' if self.map.world
                                == 'event' else 'warning_port_full.png')
            if self.regions['lower'].exists(port_full_notice):
                Util.log_warning("Canceling combat sortie: port is full.")
                self.set_next_combat_time({'minutes': 15})
                cancel_sortie = True

        if cancel_sortie:
            return False
        return True

    def _run_pre_sortie_fleet_check_logic(self, fleet):
        """Method that actually does the checking of supplies and damages of
        the fleet during the pre-sortie fleet check. Also includes special
        handling of the 7th ship in striking fleets.

        Args:
            fleet (CombatFleet): CombatFleet instance of fleet being checked

        Returns:
            bool: indicates whether or not the fleed requires resupply
            dict: dict of combat damages
            dict: dict of fleet fatigue
        """
        needs_resupply = False
        if not fleet.check_supplies(self.regions['check_supply']):
            fleet.needs_resupply = True
            needs_resupply = True
        fleet_damages = (fleet.check_damages_7th(self.module_regions)
                         if self.config.combat['fleet_mode'] == 'striking' else
                         fleet.check_damages(
                             self.module_regions['check_damage']))
        fleet.print_damage_counts(repair=True)

        if 'CheckFatigue' in self.config.combat['misc_options']:
            fleet_fatigue = fleet.check_fatigue(
                self.module_regions['check_fatigue'])
            fleet.print_fatigue_states()
            return (needs_resupply, fleet_damages, fleet_fatigue)
        return (needs_resupply, fleet_damages, {})

    def _run_combat_logic(self):
        """Method that contains the logic and fires off necessary child methods
        for resolving anything combat-related. Includes LBAS node assignment,
        compass spins, formation selects, night battle selects, FCF retreats
        for combined fleet, flagship retreats, mid-battle damage checks, and
        resource node ends.
        """
        self.stats.increment_combat_done()

        if self.lbas:
            self.lbas.assign_groups()

        self.primary_fleet.needs_resupply = True
        if self.combined_fleet:
            self.fleets[2].needs_resupply = True

        # primary combat loop
        sortieing = True
        self.nodes_run = []
        disable_combat = False
        post_combat_screens = []
        while sortieing:
            at_node, dialogue_click = self._run_loop_between_nodes()

            # stop the background observer if no longer on the map screen
            if self.config.combat['engine'] == 'live':
                self.observeRegion.stopObserver()

            if at_node:
                # arrived at combat node
                self._increment_nodes_run()

                # reset ClearStop temp variables
                if 'ClearStop' in self.config.combat['misc_options']:
                    disable_combat = False
                    post_combat_screens = []

                if dialogue_click:
                    # click to get rid of initial boss dialogue in case it
                    # exists
                    Util.kc_sleep(5)
                    Util.click_preset_region(self.regions, 'center')
                    Util.kc_sleep()
                    Util.click_preset_region(self.regions, 'center')
                    Util.rejigger_mouse(self.regions, 'lbas')

                combat_result = self._run_loop_during_battle()

                # resolve night battle
                if combat_result == 'night_battle':
                    if self._select_night_battle(self._resolve_night_battle()):
                        self._run_loop_during_battle()

                self.regions['lower_right_corner'].wait('next.png', 30)

                # battle complete; resolve combat results
                Util.click_preset_region(self.regions, 'center')
                self.regions['game'].wait('mvp_marker.png', 30)
                self.dmg = self.primary_fleet.check_damages(
                    self.module_regions['check_damage_combat'])
                self.primary_fleet.print_damage_counts()
                if 'ClearStop' in self.config.combat['misc_options']:
                    # check for a medal drop here if ClearStop is enabled
                    self.regions['lower_right_corner'].wait('next.png', 30)
                    if self.regions['right'].exists('medal_marker.png'):
                        disable_combat = True
                if self.combined_fleet:
                    self.regions['lower_right_corner'].wait('next.png', 30)
                    Util.click_preset_region(self.regions, 'center')
                    Util.kc_sleep(2)
                    self.regions['game'].wait('mvp_marker.png', 30)
                    fleet_two_damages = self.fleets[2].check_damages(
                        self.module_regions['check_damage_combat'])
                    self.fleets[2].print_damage_counts()
                    self.dmg = self._combine_fleet_damages(
                        self.dmg, fleet_two_damages)
                    # ascertain whether or not the escort fleet's flagship is
                    # damaged if necessary
                    if (fleet_two_damages['heavy'] == 1
                            and not self.fleets[2].flagship_damaged):
                        self.fleets[2].check_damage_flagship(
                            self.module_regions)
                Util.rejigger_mouse(self.regions, 'lbas')
                # click through while not next battle or home
                while not (
                        self.fast_kc_region.exists('home_menu_sortie.png') or
                        self.fast_kc_region.exists('combat_flagship_dmg.png')
                        or self.fast_kc_region.exists('combat_retreat.png')):
                    if self.regions['lower_right_corner'].exists('next.png'):
                        Util.click_preset_region(self.regions, 'center')
                        Util.rejigger_mouse(self.regions, 'top')
                        if 'ClearStop' in self.config.combat['misc_options']:
                            post_combat_screens.append('next')
                    elif self.regions['lower_right_corner'].exists(
                            'next_alt.png'):
                        Util.click_preset_region(self.regions, 'center')
                        Util.rejigger_mouse(self.regions, 'top')
                        if 'ClearStop' in self.config.combat['misc_options']:
                            post_combat_screens.append('next_alt')
                    if self.map.world == 'event':
                        # if the 'next' asset exists in this region during an
                        # event map sortie, the map is cleared
                        if self.module_regions['event_next'].exists(
                                'next.png'):
                            Util.click_preset_region(self.regions, 'center')
                            Util.rejigger_mouse(self.regions, 'top')
                            disable_combat = True
                    if self.combined_fleet or self.striking_fleet:
                        self._resolve_fcf()
                        Util.rejigger_mouse(self.regions, 'top')

            if self.regions['left'].exists('home_menu_sortie.png'):
                # arrived at home; sortie complete
                self._print_sortie_complete_msg(self.nodes_run)
                sortieing = False
                break

            if self.regions['lower_right_corner'].exists(
                    'combat_flagship_dmg.png'):
                # flagship retreat; sortie complete
                Util.log_msg("Flagship damaged. Automatic retreat.")
                Util.click_preset_region(self.regions, 'game')
                self.regions['left'].wait('home_menu_sortie.png', 30)
                self._print_sortie_complete_msg(self.nodes_run)
                sortieing = False
                break

            if self.regions['lower_right_corner'].exists('next_alt.png'):
                # resource node end; sortie complete
                while not self.regions['left'].exists('home_menu_sortie.png'):
                    if self.regions['lower_right_corner'].exists('next.png'):
                        Util.click_preset_region(self.regions, 'center')
                        Util.rejigger_mouse(self.regions, 'top')
                    elif self.regions['lower_right_corner'].exists(
                            'next_alt.png'):
                        Util.click_preset_region(self.regions, 'center')
                        Util.rejigger_mouse(self.regions, 'top')
                self._print_sortie_complete_msg(self.nodes_run)
                sortieing = False
                break

            if self.kc_region.exists('combat_retreat.png'):
                continue_sortie = self._resolve_continue_sortie()

                # resolve retreat/continue
                if continue_sortie:
                    self._select_continue_sortie(True)
                else:
                    self._select_continue_sortie(False)
                    self.regions['left'].wait('home_menu_sortie.png', 30)
                    self._print_sortie_complete_msg(self.nodes_run)
                    sortieing = False
                    break
        # after sortie is complete, check the dismissed post-combat screens to
        # see if combat should be disabled
        if ('ClearStop' in self.config.combat['misc_options']
                and not disable_combat):
            # TODO: additional logic needed to resolve end of 1-6
            if self.map.world == 'event' and len(post_combat_screens) > 2:
                # event map and more than 2 post-combat screens dismissed;
                # assume that it means that event map is cleared
                disable_combat = True

        # if the disable combat flag is set, disable the combat module
        if disable_combat:
            self.disable_module()

    def _print_sortie_complete_msg(self, nodes_run):
        """Method that prints the post-sortie status report indicating number
        of nodes run and nodes run.

        Args:
            nodes_run (list): list of combat node numbers (legacy mode) or
                Nodes instances (live mode) run in the primary combat logic
        """
        Util.log_success(
            "Sortie complete. Encountered {} combat nodes (nodes {}).".format(
                len(nodes_run), ', '.join(str(node) for node in nodes_run)))

    def _run_loop_between_nodes(self):
        """Method that continuously checks for the next update between combat
        nodes. Resolves compass spins, formation selects, node selects, and
        resource node ends.

        Returns:
            bool: True if the method ends on a combat node, False otherwise
            bool: True if the click to remove boss dialogue should be done,
                False otherwise (only applicable if first bool is True)
        """
        at_node = False

        # if in live engine mode, begin the background observer to track and
        # update the fleet position
        if self.config.combat['engine'] == 'live':
            self._start_fleet_observer()

        while not at_node:
            if self.fast_kc_region.exists('compass.png'):
                # spin compass
                while (self.kc_region.exists('compass.png')):
                    Util.click_preset_region(self.regions, 'center')
                    Util.rejigger_mouse(self.regions, 'lbas')
                    Util.kc_sleep(3)
            elif (self.regions['formation_line_ahead'].exists(
                    'formation_line_ahead.png')
                  or self.regions['formation_combinedfleet_1'].exists(
                      'formation_combinedfleet_1.png')):
                # check for both single fleet and combined fleet formations
                # since combined fleets can have single fleet battles
                self._print_current_node()
                formations = self._resolve_formation()
                for formation in formations:
                    if self._select_formation(formation):
                        break
                Util.rejigger_mouse(self.regions, 'lbas')
                at_node = True
                return (True, True)
            elif self.fast_kc_region.exists('combat_node_select.png'):
                # node select dialog option exists; resolve fleet location and
                # select node
                if self.config.combat['engine'] == 'legacy':
                    # only need to manually update self.current_node if in
                    # legacy engine mode
                    self._update_fleet_position_once()
                if (self.current_node.name
                        in self.config.combat['node_selects']):
                    next_node = self.config.combat['node_selects'][
                        self.current_node.name]
                    Util.log_msg("Selecting Node {} from Node {}.".format(
                        next_node, self.current_node))
                    self.map.nodes[next_node].click_node(self.regions['game'])
                    Util.rejigger_mouse(self.regions, 'lbas')
            elif (self.regions['lower_right_corner'].exists('next.png')
                  or self.fast_kc_region.exists('combat_nb_fight.png')):
                # post-combat or night battle select without selecting a
                # formation
                self._print_current_node()
                Util.rejigger_mouse(self.regions, 'lbas')
                at_node = True
                return (True, False)
            elif self.regions['lower_right_corner'].exists(
                    'combat_flagship_dmg.png'):
                # flagship retreat
                return (False, False)
            elif self.regions['lower_right_corner'].exists('next_alt.png'):
                # resource node end
                return (False, False)

    def _run_loop_during_battle(self):
        """Method that continuously runs during combat for the night battle
        prompt or battle end screen.

        Returns:
            str: 'night_battle' if combat ends on the night battle prompt,
                'results' if otherwise
        """
        while True:
            if self.kc_region.exists('combat_nb_fight.png'):
                return 'night_battle'
            elif self.regions['lower_right_corner'].exists('next.png'):
                return 'results'
            else:
                pass

    def _start_fleet_observer(self):
        """Method that starts the observeRegion/observeInBackground methods
        that tracks the fleet position icon in real-time in the live engine
        mode.
        """
        self.observeRegion.onAppear(
            Pattern(self.fleet_icon).similar(Globals.FLEET_ICON_SIMILARITY),
            self._update_fleet_position)
        self.observeRegion.observeInBackground(FOREVER)

    def _stop_fleet_observer(self):
        """Stops the observer started by the _start_fleet_observer() method.
        """
        self.observeRegion.stopObserver()

    def _update_fleet_position(self, event):
        """Method that is run by the fleet observer to continuously update the
        fleet's status.

        Args:
            event (event): sikuli observer event
        """
        fleet_match = event.getMatch()
        # lastMatch is based off of screen positions, so subtract game region
        # x and y to get in-game positions
        self.current_position = [
            fleet_match.x + (fleet_match.w / 2) - self.kc_region.x,
            fleet_match.y + fleet_match.h - self.kc_region.y
        ]

        # debug console print for the observer's found position of the fleet
        """
        print(
            "{}, {} ({})".format(
                self.current_position[0], self.current_position[1],
                fleet_match))
        """
        matched_node = self.map.find_node_by_pos(*self.current_position)
        self.current_node = (matched_node if matched_node is not None else
                             self.current_node)
        event.repeat()

    def _update_fleet_position_once(self):
        """Method that can be called to find and update the fleet's position
        on-demand.
        """
        fleet_match = self.kc_region.find(
            Pattern(self.fleet_icon).similar(Globals.FLEET_ICON_SIMILARITY))
        # lastMatch is based off of screen positions, so subtract game region
        # x and y to get in-game positions
        self.current_position = [
            fleet_match.x + (fleet_match.w / 2) - self.kc_region.x,
            fleet_match.y + fleet_match.h - self.kc_region.y
        ]

        # debug console print for the method's found position of the fleet
        """
        print(
            "{}, {} ({})".format(
                self.current_position[0], self.current_position[1],
                fleet_match))
        """
        matched_node = self.map.find_node_by_pos(*self.current_position)
        self.current_node = (matched_node if matched_node is not None else
                             self.current_node)
        Util.log_msg("Fleet at node {}.".format(self.current_node))

    def _increment_nodes_run(self):
        """Method to properly append to the nodes_run attribute; the combat
        node number if the engine is in legacy mode, otherwise with the Node
        instance of the encountered node if in live mode
        """
        if self.config.combat['engine'] == 'legacy':
            self.nodes_run.append(len(self.nodes_run) + 1)
        elif self.config.combat['engine'] == 'live':
            self.nodes_run.append(self.current_node)

    def _print_current_node(self):
        """Method to print out which node the fleet is at. Behavior differs
        depending on the combat engine mode.
        """
        if self.config.combat['engine'] == 'legacy':
            Util.log_msg("Fleet at Node #{}".format(len(self.nodes_run) + 1))
        if self.config.combat['engine'] == 'live':
            Util.log_msg("Fleet at Node {}".format(self.current_node))

    def _resolve_formation(self):
        """Method to resolve which formation to select depending on the combat
        engine mode and any custom specified formations.

        Returns:
            tuple: tuple of formations to try in order
        """
        # +1 since this happens before entering a node
        next_node_count = len(self.nodes_run) + 1
        custom_formations = self.config.combat['formations']

        if self.config.combat['engine'] == 'legacy':
            # if legacy engine, custom formation can only be applied on a node
            # count basis; if a custom formation is not defined, default to
            # combinedfleet_4 or line_ahead
            if next_node_count in custom_formations:
                Util.log_msg("Custom formation specified for node #{}.".format(
                    next_node_count))
                return (custom_formations[next_node_count], )
            else:
                Util.log_msg(
                    "No custom formation specified for node #{}.".format(
                        next_node_count))
                return ('combinedfleet_4'
                        if self.combined_fleet else 'line_ahead', )
        elif self.config.combat['engine'] == 'live':
            # if live engine, custom formation can be applied by node name or
            # node count; if a custom formation is not defined, defer to the
            # mapData instance's resolve_formation method
            if (self.current_node
                    and self.current_node.name in custom_formations):
                Util.log_msg("Custom formation specified for node {}.".format(
                    self.current_node.name))
                return (custom_formations[self.current_node.name], )
            elif next_node_count in custom_formations:
                Util.log_msg("Custom formation specified for node #{}.".format(
                    next_node_count))
                return (custom_formations[next_node_count], )
            else:
                Util.log_msg(
                    "Formation specified for node {} via map data.".format(
                        self.current_node.name))
                return self.map.resolve_formation(self.current_node)

    def _resolve_night_battle(self):
        """Method to resolve whether or not to conduct night battle depending
        on the combat engine mode and any custom specified night battle modes.

        Returns:
            bool: True if night battle should be conducted, False otherwise
        """
        # no +1 since this happens after entering a node
        next_node_count = len(self.nodes_run)
        custom_night_battles = self.config.combat['night_battles']

        if self.config.combat['engine'] == 'legacy':
            # if legacy engine, custom night battle modes can only be applied
            # on a node count basis; if a custom night battle mode is not
            # defined, default to True
            if next_node_count in custom_night_battles:
                Util.log_msg(
                    "Custom night battle specified for node #{}.".format(
                        next_node_count))
                return custom_night_battles[next_node_count]
            else:
                Util.log_msg("No night battle specified for node #{}.".format(
                    next_node_count))
                return False
        elif self.config.combat['engine'] == 'live':
            # if live engine, custom night battle modes can be applied by node
            # name or node count; if a custom night battle mode is not defined,
            # defer to the mapData instance's resolve_night_battle method
            if (self.current_node
                    and self.current_node.name in custom_night_battles):
                Util.log_msg(
                    "Custom night battle specified for node {}.".format(
                        self.current_node.name))
                return custom_night_battles[self.current_node.name]
            elif next_node_count in custom_night_battles:
                Util.log_msg(
                    "Custom night battle specified for node #{}.".format(
                        next_node_count))
                return custom_night_battles[next_node_count]
            else:
                Util.log_msg(
                    "Night battle specified for node {} via map data.".format(
                        self.current_node.name))
                return self.map.resolve_night_battle(self.current_node)

    def _resolve_continue_sortie(self):
        """Method to resolve whether or not to continue the sortie based on
        number of nodes run, map data (if applicable), and damage counts.

        Returns:
            bool: True if sortie should be continued, False otherwise
        """
        # check whether to retreat against combat nodes count
        if len(self.nodes_run) >= self.config.combat['combat_nodes']:
            Util.log_msg("Ran the necessary number of nodes. Retreating.")
            return False

        # if on live engine mode, check if the current node is a retreat node
        if self.config.combat['engine'] == 'live':
            if not self.map.resolve_continue_sortie(self.current_node):
                Util.log_msg("Node {} is a retreat node. Retreating.".format(
                    self.current_node))
                return False

        # check whether to retreat against fleet damage state
        threshold_dmg_count = (
            self.primary_fleet.get_damage_counts_at_threshold(
                self.config.combat['retreat_limit'], self.dmg))
        if threshold_dmg_count > 0:
            continue_override = False
            if self.combined_fleet and threshold_dmg_count == 1:
                # if there is only one heavily damaged ship and it is
                # the flagship of the escort fleet, do not retreat
                if (self.fleets[2].damage_counts['heavy'] == 1
                        and self.fleets[2].flagship_damaged):
                    continue_override = True
                    Util.log_msg(
                        "The 1 ship damaged beyond threshold is the escort "
                        "fleet's flagship (unsinkable). Continuing sortie.")
            if not continue_override:
                Util.log_warning(
                    "{} ship(s) damaged above threshold. Retreating.".format(
                        threshold_dmg_count))
                return False
        return True

    def _select_formation(self, formation):
        """Method that selects the specified formation on-screen.

        Args:
            formation (str): formation to select

        Returns:
            bool: True if the formation was clicked, False if its button could
                not be found
        """
        Util.log_msg("Engaging the enemy in {} formation.".format(
            formation.replace('_', ' ')))
        return Util.check_and_click(
            self.regions['formation_{}'.format(formation)],
            'formation_{}.png'.format(formation))

    def _select_night_battle(self, nb):
        """Method that selects the night battle sortie button or retreats
        from it.

        Args:
            nb (bool): indicates whether or not night battle should be done or
                not

        Returns:
            bool: True if night battle was initiated, False otherwise
        """
        if nb:
            Util.log_msg("Commencing night battle.")
            Util.check_and_click(self.kc_region, 'combat_nb_fight.png')
            self.kc_region.waitVanish('combat_nb_fight.png')
            Util.kc_sleep()
            return True
        else:
            Util.log_msg("Declining night battle.")
            Util.check_and_click(self.kc_region, 'combat_nb_retreat.png')
            self.kc_region.waitVanish('combat_nb_retreat.png')
            Util.kc_sleep()
            return False

    def _select_continue_sortie(self, continue_sortie):
        """Method that selects the sortie continue or retreat button.

        Args:
            continue_sortie (bool): True if the the sortie continue button
            should be pressed, False otherwise
        """
        if continue_sortie:
            Util.log_msg("Continuing sortie.")
            Util.check_and_click(self.kc_region, 'combat_continue.png')
            self.kc_region.waitVanish('combat_continue.png')
            Util.kc_sleep()
        else:
            Util.log_msg("Retreating from sortie.")
            Util.check_and_click(self.kc_region, 'combat_retreat.png')
            self.kc_region.waitVanish('combat_retreat.png')
            Util.kc_sleep()

    def _resolve_fcf(self):
        """Method that resolves the FCF prompt. Does not use FCF if there are
        more than one ship in a heavily damaged state. Supports both combined
        fleet FCF and striking force FCF
        """
        if self.regions['lower_left'].exists('fcf_retreat_ship.png'):
            fcf_retreat = False
            if self.combined_fleet:
                # for combined fleets, check the heavy damage counts of both
                # fleets 1 and 2
                fleet_1_heavy_damage = self.fleets[1].damage_counts['heavy']
                fleet_2_heavy_damage = self.fleets[2].damage_counts['heavy']
                if fleet_1_heavy_damage + fleet_2_heavy_damage == 1:
                    fcf_retreat = True
                    self.fleets[1].increment_fcf_retreat_count()
                    self.fleets[2].increment_fcf_retreat_count()
            elif self.striking_fleet:
                # for striking fleets, check the heavy damage counts of the
                # 3rd fleet
                if self.fleets[3].damage_counts['heavy'] == 1:
                    fcf_retreat = True
                    self.fleets[3].increment_fcf_retreat_count()

            if fcf_retreat:
                if (Util.check_and_click(self.regions['lower'],
                                         'fcf_retreat_ship.png')):
                    # decrement the Combat module's internal dmg count so it
                    # knows to continue sortie to the next node
                    self.dmg['heavy'] -= 1
            else:
                Util.log_warning("Declining to retreat ship with FCF.")
                Util.check_and_click(self.regions['lower'],
                                     'fcf_continue_fleet.png')

    def _combine_fleet_damages(self, main, escort):
        """Method for conveniently combining two damage dicts for combined
        fleets.

        Args:
            main (dict): damage dict of main fleet
            escort (dict): damage dict of escort fleet

        Returns:
            dict: damage dict aggregating all damage counts for both main and
                escort fleets
        """
        combined = {}  # create new to not update by reference
        for key in main:
            combined[key] = main[key] + escort[key]
        return combined

    def disable_module(self):
        Util.log_success("De-activating the combat module.")
        self.enabled = False
        self.disabled_time = datetime.now()

    def enable_module(self):
        Util.log_success("Re-activating the combat module.")
        self.enabled = True
        self.disabled_time = None

    def print_status(self):
        """Method that prints the next sortie time status of the Combat module.
        """
        if self.enabled:
            Util.log_success("Next combat sortie at {}".format(
                self.next_combat_time.strftime('%Y-%m-%d %H:%M:%S')))
        else:
            Util.log_success("Combat module disabled as of {}".format(
                self.disabled_time.strftime('%Y-%m-%d %H:%M:%S')))