From 74a78da13becf144416bc7644bb31830f0833bb1 Mon Sep 17 00:00:00 2001 From: Neka Date: Thu, 5 Dec 2024 18:35:08 +0100 Subject: [PATCH] Level Grind : Rework level only first one in party (#525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor Level grind no rotate lead strategy * Rework move dmg calculation functions * Fix bot getting stuck with confirmation window * Simplify confirmation window choices * Fix in battle indexes when switching after faint * Move no rotate lead strategy to a dedicated strategy * Fix battling party indexes * Fixing names and move damage calculation for level grind * Fixing names and move damage calculation for level grind * Do not stop when flee chance is null * Fix calls to function that no longer exists * Apply party index mapping in `handle_fainted_pokemon()` if necessary * Do not raise when Pokemon can't battle * Check if tile has encounters before displaying choices window * Rework get strongest move to not stop if no damaging moves * Simplify current battler retrieval in battling strategies * Refacto default strategy to better handle move selection * Add a fix where the bot would not go heal if the lead Pokémon was out of PP --------- Co-authored-by: Tino --- modules/battle_handler.py | 18 ++ modules/battle_strategies/_util.py | 167 +++++++++++++++--- modules/battle_strategies/default.py | 173 +++++++++---------- modules/battle_strategies/level_balancing.py | 3 +- modules/battle_strategies/level_up.py | 84 +++++++++ modules/battle_strategies/run_away.py | 27 ++- modules/gui/multi_select_window.py | 61 +++++++ modules/modes/_asserts.py | 22 ++- modules/modes/level_grind.py | 151 ++++++++++------ 9 files changed, 511 insertions(+), 195 deletions(-) create mode 100644 modules/battle_strategies/level_up.py diff --git a/modules/battle_handler.py b/modules/battle_handler.py index 6728ef274..6944c8cbd 100644 --- a/modules/battle_handler.py +++ b/modules/battle_handler.py @@ -91,6 +91,21 @@ def handle_fainted_pokemon(strategy: BattleStrategy): return new_lead_index = strategy.choose_new_lead_after_faint(battle_state) + + # If `choose_new_lead_after_faint()` has been called while NOT being in the party selection screen, + # `get_party()` still contains the 'original' (overworld) party order. Thus, we have to map the new + # index to the in-battle party index (because that's what we're going to select later) but only after + # all the sanity checks have been done. + # On the other hand, if this function was called IN the party menu, `get_party()` already returns the + # in-battle order and no mapping is needed. + # + # In practice, this function will be called OUTSIDE the party menu if the battle strategy chose to + # send out the next Pokémon (without trying to escape) because then the call happens during the + # dialogue. + # Whereas the function will be called IN the party menu if the strategy tried to escape and failed, + # because then the game automatically opens the party menu. + index_needs_mapping = get_game_state() != GameState.PARTY_MENU + if context.bot_mode == "Manual": yield return @@ -121,6 +136,9 @@ def handle_fainted_pokemon(strategy: BattleStrategy): if get_current_battle_script_instruction() == "BattleScript_FaintedMonEnd": return + if index_needs_mapping: + new_lead_index = battle_state.map_battle_party_index(new_lead_index) + yield from scroll_to_party_menu_index(new_lead_index) while get_game_state() == GameState.PARTY_MENU: context.emulator.press_button("A") diff --git a/modules/battle_strategies/_util.py b/modules/battle_strategies/_util.py index 26f74afe8..88099a433 100644 --- a/modules/battle_strategies/_util.py +++ b/modules/battle_strategies/_util.py @@ -1,16 +1,15 @@ import math from typing import TYPE_CHECKING -from modules.battle_state import Weather, TemporaryStatus, BattleType +from modules.battle_state import BattlePokemon, BattleState, Weather, TemporaryStatus, BattleType from modules.battle_strategies import TurnAction from modules.context import context from modules.items import ItemHoldEffect, get_item_bag, get_item_by_name from modules.memory import get_event_flag, read_symbol -from modules.pokemon import StatusCondition, get_type_by_name, get_ability_by_name +from modules.pokemon import StatusCondition, Pokemon, LearnedMove, get_type_by_name, get_ability_by_name, get_party from modules.modes._interface import BotModeError if TYPE_CHECKING: - from modules.battle_state import BattlePokemon, BattleState from modules.pokemon import Move, Type @@ -164,9 +163,15 @@ def can_switch(self) -> bool: return True def calculate_move_damage_range( - self, move: "Move", attacker: "BattlePokemon", defender: "BattlePokemon", is_critical_hit: bool = False + self, + move: "Move", + attacker: "Pokemon | BattlePokemon", + defender: "BattlePokemon", + is_critical_hit: bool = False, ) -> DamageRange: # todo: Bide, Counter, Endeavor, Mirror Coat + defender_types = defender.species.types if isinstance(defender, Pokemon) else defender.types + attacker_types = attacker.species.types if isinstance(attacker, Pokemon) else attacker.types damage = self._calculate_base_move_damage(move, attacker, defender, is_critical_hit) @@ -177,7 +182,7 @@ def calculate_move_damage_range( if defender.ability.name == "Wonder Guard": super_effective = False - for defender_type in defender.types: + for defender_type in defender_types: if move_type.get_effectiveness_against(defender_type) > 1: super_effective = True break @@ -200,11 +205,11 @@ def calculate_move_damage_range( return DamageRange(0) # Same-type Attack Bonus - if move_type in attacker.types: + if move_type in attacker_types: damage = _percentage(damage, 150) # Type effectiveness - for defender_type in defender.types: + for defender_type in defender_types: damage = _percentage(damage, int(100 * move_type.get_effectiveness_against(defender_type))) if move.name == "Dragon Rage": @@ -225,30 +230,40 @@ def calculate_move_damage_range( if is_critical_hit: damage *= 2 - if TemporaryStatus.ChargedUp in attacker.status_temporary and move_type.name == "Electric": + if ( + isinstance(attacker, BattlePokemon) + and TemporaryStatus.ChargedUp in attacker.status_temporary + and move_type.name == "Electric" + ): damage *= 2 # todo: Helping Hand return DamageRange(max(1, _percentage(damage, 85)), damage) - def get_strongest_move_against(self, pokemon: "BattlePokemon", opponent: "BattlePokemon"): + def get_strongest_move_against(self, pokemon: "Pokemon | BattlePokemon", opponent: "BattlePokemon") -> int | None: + """ + Determines the strongest move that a Pokémon can use against an opponent. + Supports both `Pokemon` and `BattlePokemon` for the ally parameter. + Raises BotModeError if no usable moves are found. + """ + # Retrieve moves based on the type of the Pokémon object + moves = [move for move in pokemon.moves if move is not None] + move_strengths = [] - for learned_move in pokemon.moves: + for learned_move in moves: if learned_move.move.name in context.config.battle.banned_moves: move_strengths.append(-1) continue move = learned_move.move - if learned_move.pp == 0 or pokemon.disabled_move is move: + if learned_move.pp == 0 or (isinstance(pokemon, BattlePokemon) and pokemon.disabled_move is move): move_strengths.append(-1) else: move_strengths.append(self.calculate_move_damage_range(move, pokemon, opponent).max) max_strength = max(move_strengths) if max_strength <= 0: - raise BotModeError( - f"{pokemon.species.name} does not know any damage-dealing moves, or they are forbidden to use by bot configuration" - ) + return None strongest_move = move_strengths.index(max_strength) return strongest_move @@ -360,6 +375,16 @@ def _calculate_base_move_damage( defence *= 2 # Abilities + if isinstance(attacker, Pokemon): + attacker_status = attacker.status_condition + else: + attacker_status = attacker.status_permanent + + if isinstance(defender, Pokemon): + defender_status = defender.status_condition + else: + defender_status = defender.status_permanent + if defender.ability.name == "Thick Fat" and move_type.name in ("Fire", "Ice"): special_attack //= 2 if attacker.ability.name == "Hustle": @@ -369,9 +394,9 @@ def _calculate_base_move_damage( special_attack = _percentage(special_attack, 150) if attacker.ability.name == "Minus" and attacker_partner.ability.name == "Plus": special_attack = _percentage(special_attack, 150) - if attacker.ability.name == "Guts" and attacker.status_permanent is not StatusCondition.Healthy: + if attacker.ability.name == "Guts" and attacker_status is not StatusCondition.Healthy: attack = _percentage(attack, 150) - if defender.ability.name == "Marvel Scale" and attacker.status_permanent is not StatusCondition.Healthy: + if defender.ability.name == "Marvel Scale" and attacker_status is not StatusCondition.Healthy: defence = _percentage(defence, 150) # todo: @@ -395,23 +420,29 @@ def _calculate_base_move_damage( damage = 0 if move_type.is_physical: - if is_critical_hit and attacker.stats_modifiers.attack <= 0: - damage = attack + if isinstance(attacker, BattlePokemon): + if is_critical_hit and attacker.stats_modifiers.attack <= 0: + damage = attack + else: + damage = _calculate_modified_stat(attack, attacker.stats_modifiers.attack) else: - damage = _calculate_modified_stat(attack, attacker.stats_modifiers.attack) + damage = attack damage *= move_power damage *= 2 * attacker.level // 5 + 2 - if is_critical_hit and defender.stats_modifiers.defence > 0: - damage //= defence + if isinstance(defender, BattlePokemon): + if is_critical_hit and defender.stats_modifiers.defence > 0: + damage //= defence + else: + damage //= _calculate_modified_stat(defence, defender.stats_modifiers.defence) else: - damage //= _calculate_modified_stat(defence, defender.stats_modifiers.defence) + damage //= defence damage //= 50 # Burn cuts attack in half - if attacker.status_permanent is StatusCondition.Burn: + if attacker_status is StatusCondition.Burn: damage //= 2 # Reflect @@ -433,18 +464,22 @@ def _calculate_base_move_damage( damage = 0 if move_type.is_special: - if is_critical_hit and attacker.stats_modifiers.special_attack <= 0: + if is_critical_hit and isinstance(attacker, BattlePokemon) and attacker.stats_modifiers.special_attack <= 0: damage = special_attack - else: + elif isinstance(attacker, BattlePokemon): damage = _calculate_modified_stat(special_attack, attacker.stats_modifiers.special_attack) + else: + damage = special_attack damage *= move_power damage *= 2 * attacker.level // 5 + 2 - if is_critical_hit and defender.stats_modifiers.special_defence > 0: + if is_critical_hit and isinstance(defender, BattlePokemon) and defender.stats_modifiers.special_defence > 0: damage //= special_defence - else: + elif isinstance(defender, BattlePokemon): damage //= _calculate_modified_stat(special_defence, defender.stats_modifiers.special_defence) + else: + damage //= special_defence damage //= 50 @@ -483,3 +518,81 @@ def _calculate_base_move_damage( # todo: Flash Fire return damage + 2 + + def get_potential_rotation_targets(self, battle_state: BattleState | None = None) -> list[int]: + """ + Returns the indices of party Pokémon that are usable for battle. + A Pokémon is considered usable if it has enough HP, is not an egg, + is not already active, and has a valid move to damage the opponent. + """ + active_party_indices = [] + if battle_state is not None: + if battle_state.own_side.left_battler is not None: + active_party_indices.append(battle_state.own_side.left_battler.party_index) + if battle_state.own_side.right_battler is not None: + active_party_indices.append(battle_state.own_side.right_battler.party_index) + + party = get_party() + usable_pokemon = [] + + for index, pokemon in enumerate(party): + # Skip eggs, fainted Pokémon, or already active Pokémon + if pokemon.is_egg or not self.pokemon_has_enough_hp(pokemon) or index in active_party_indices: + continue + + # Check if the Pokémon has any move that can deal damage to the opponent + if battle_state is not None and battle_state.opponent.active_battler is not None: + opponent = battle_state.opponent.active_battler + + if self.get_strongest_move_against(pokemon, opponent) is not None: + usable_pokemon.append(index) + else: + # If there's no opponent context, fall back to checking move usability + if any(self.move_is_usable(move) for move in pokemon.moves): + usable_pokemon.append(index) + + return usable_pokemon + + def select_rotation_target(self, battle_state: BattleState | None = None) -> int | None: + indices = self.get_potential_rotation_targets(battle_state) + if len(indices) == 0: + return None + + party = get_party() + values = [] + for index in indices: + pokemon = party[index] + if context.config.battle.switch_strategy == "lowest_level": + value = 100 - pokemon.level + else: + value = pokemon.current_hp + if pokemon.status_condition in (StatusCondition.Sleep, StatusCondition.Freeze): + value *= 0.25 + elif pokemon.status_condition == StatusCondition.BadPoison: + value *= 0.5 + elif pokemon.status_condition in ( + StatusCondition.BadPoison, + StatusCondition.Poison, + StatusCondition.Burn, + ): + value *= 0.65 + elif pokemon.status_condition == StatusCondition.Paralysis: + value *= 0.8 + + values.append(value) + + best_value = max(values) + index = indices[values.index(best_value)] + + return index + + def move_is_usable(self, move: LearnedMove): + return ( + move is not None + and move.move.base_power > 0 + and move.pp > 0 + and move.move.name not in context.config.battle.banned_moves + ) + + def pokemon_has_enough_hp(self, pokemon: Pokemon | BattlePokemon): + return pokemon.current_hp_percentage > context.config.battle.hp_threshold diff --git a/modules/battle_strategies/default.py b/modules/battle_strategies/default.py index 8e1460b3b..fed2099cb 100644 --- a/modules/battle_strategies/default.py +++ b/modules/battle_strategies/default.py @@ -1,6 +1,8 @@ from modules.battle_state import BattleState, BattlePokemon, get_battle_state from modules.context import context from modules.pokemon import get_party, Pokemon, Move, LearnedMove, StatusCondition +from modules.modes._interface import BotModeError +from typing import Optional from ._interface import BattleStrategy, TurnAction, SafariTurnAction from ._util import BattleStrategyUtil @@ -16,7 +18,7 @@ def __init__(self): def party_can_battle(self) -> bool: return any(self.pokemon_can_battle(pokemon) for pokemon in get_party()) - def pokemon_can_battle(self, pokemon: Pokemon): + def pokemon_can_battle(self, pokemon: Pokemon) -> bool: if pokemon.is_egg or not self._pokemon_has_enough_hp(pokemon): return False @@ -140,32 +142,26 @@ def choose_new_lead_after_faint(self, battle_state: BattleState) -> int: def choose_new_lead_after_battle(self) -> int | None: party = get_party() if not self.pokemon_can_battle(party[self._first_non_fainted_party_index_before_battle]): - return self._select_rotation_target() + return util.select_rotation_target() return None - def decide_turn(self, battle_state: BattleState) -> tuple["TurnAction", any]: + def decide_turn(self, battle_state: BattleState) -> tuple["TurnAction", Optional[any]]: + """ + Decides the action to take for the current turn based on the battle state. + """ util = BattleStrategyUtil(battle_state) - if not self._pokemon_has_enough_hp(get_party()[battle_state.own_side.active_battler.party_index]): - if context.config.battle.lead_cannot_battle_action == "flee" and not battle_state.is_trainer_battle: - # If it is impossible to escape, do not even attempt to do it but just keep battling. - best_escape_method = util.get_best_escape_method() - if best_escape_method is not None: - return best_escape_method - elif ( - context.config.battle.lead_cannot_battle_action == "rotate" - and len(self._get_usable_party_indices(battle_state)) > 0 - ): - if util.can_switch(): - return TurnAction.rotate_lead(self._select_rotation_target(battle_state)) - else: - context.message = "Leading Pokémon's HP fell below the minimum threshold." - return TurnAction.switch_to_manual() + if not util.pokemon_has_enough_hp(battle_state.own_side.active_battler): + return self._handle_lead_cannot_battle(battle_state, util, reason="HP below threshold") - return TurnAction.use_move( - util.get_strongest_move_against(battle_state.own_side.active_battler, battle_state.opponent.active_battler) + strongest_move = util.get_strongest_move_against( + battle_state.own_side.active_battler, battle_state.opponent.active_battler ) + if strongest_move is not None: + return TurnAction.use_move(strongest_move) + + return self._handle_lead_cannot_battle(battle_state, util, reason="No damaging moves available") def decide_turn_in_double_battle(self, battle_state: BattleState, battler_index: int) -> tuple["TurnAction", any]: util = BattleStrategyUtil(battle_state) @@ -174,85 +170,27 @@ def decide_turn_in_double_battle(self, battle_state: BattleState, battler_index: pokemon = get_party()[battler.party_index] partner_pokemon = get_party()[partner.party_index] if partner is not None else None - if not self._pokemon_has_enough_hp(pokemon): - if partner_pokemon is None or not self._pokemon_has_enough_hp(partner_pokemon): - if context.config.battle.lead_cannot_battle_action == "flee" and not battle_state.is_trainer_battle: - # If it is impossible to escape, do not even attempt to do it but just keep battling. - best_escape_method = util.get_best_escape_method() - if best_escape_method is not None: - return best_escape_method - elif ( - context.config.battle.lead_cannot_battle_action == "rotate" - and len(self._get_usable_party_indices(battle_state)) > 0 - ): - if util.can_switch(): - return TurnAction.rotate_lead(self._select_rotation_target(battle_state)) - else: - context.message = "Both battling Pokémon's HP fell below the minimum threshold." - return TurnAction.switch_to_manual() - - if battle_state.opponent.left_battler is not None: - opponent = battle_state.opponent.left_battler - return TurnAction.use_move_against_left_side_opponent(util.get_strongest_move_against(battler, opponent)) - else: - opponent = battle_state.opponent.right_battler - return TurnAction.use_move_against_right_side_opponent(util.get_strongest_move_against(battler, opponent)) - - def decide_turn_in_safari_zone(self, battle_state: BattleState) -> tuple["SafariTurnAction", any]: - return SafariTurnAction.switch_to_manual() - - def _pokemon_has_enough_hp(self, pokemon: Pokemon | BattlePokemon): - return pokemon.current_hp_percentage > context.config.battle.hp_threshold + if not util.pokemon_has_enough_hp(pokemon): + if partner_pokemon is None or not util.pokemon_has_enough_hp(partner_pokemon): + return self.handle_lead_cannot_battle(battle_state, util) - def _get_usable_party_indices(self, battle_state: BattleState | None = None) -> list[int]: - active_party_indices = [] - if battle_state is not None: - if battle_state.own_side.left_battler is not None: - active_party_indices.append(battle_state.own_side.left_battler.party_index) - if battle_state.own_side.right_battler is not None: - active_party_indices.append(battle_state.own_side.right_battler.party_index) + left_opponent = battle_state.opponent.left_battler + right_opponent = battle_state.opponent.right_battler - party = get_party() - usable_pokemon = [] - for index in range(len(party)): - pokemon = party[index] - if self.pokemon_can_battle(pokemon) and index not in active_party_indices: - usable_pokemon.append(index) + if left_opponent is not None: + strongest_move = util.get_strongest_move_against(battler, left_opponent) + if strongest_move is not None: + return TurnAction.use_move_against_left_side_opponent(strongest_move) - return usable_pokemon + if right_opponent is not None: + strongest_move = util.get_strongest_move_against(battler, right_opponent) + if strongest_move is not None: + return TurnAction.use_move_against_right_side_opponent(strongest_move) - def _select_rotation_target(self, battle_state: BattleState | None = None) -> int | None: - indices = self._get_usable_party_indices(battle_state) - if len(indices) == 0: - return None + return self._handle_lead_cannot_battle(battle_state, util, reason="No damaging moves available") - party = get_party() - values = [] - for index in indices: - pokemon = party[index] - if context.config.battle.switch_strategy == "lowest_level": - value = 100 - pokemon.level - else: - value = pokemon.current_hp - if pokemon.status_condition in (StatusCondition.Sleep, StatusCondition.Freeze): - value *= 0.25 - elif pokemon.status_condition == StatusCondition.BadPoison: - value *= 0.5 - elif pokemon.status_condition in ( - StatusCondition.BadPoison, - StatusCondition.Poison, - StatusCondition.Burn, - ): - value *= 0.65 - elif pokemon.status_condition == StatusCondition.Paralysis: - value *= 0.8 - - values.append(value) - - best_value = max(values) - index = indices[values.index(best_value)] - - return index + def decide_turn_in_safari_zone(self, battle_state: BattleState) -> tuple["SafariTurnAction", any]: + return SafariTurnAction.switch_to_manual() def _move_is_usable(self, move: LearnedMove): return ( @@ -261,3 +199,50 @@ def _move_is_usable(self, move: LearnedMove): and move.pp > 0 and move.move.name not in context.config.battle.banned_moves ) + + def _pokemon_has_enough_hp(self, pokemon: Pokemon | BattlePokemon): + return pokemon.current_hp_percentage > context.config.battle.hp_threshold + + def _handle_lead_cannot_battle( + self, battle_state: BattleState, util: BattleStrategyUtil, reason: str + ) -> tuple["TurnAction", Optional[any]]: + """ + Handles situations where the lead Pokémon cannot battle due to low HP or lack of damaging moves. + """ + lead_action = context.config.battle.lead_cannot_battle_action + + if lead_action == "flee": + return self._handle_flee(battle_state, util, reason) + + if lead_action == "rotate": + return self._handle_rotate(battle_state, util) + + raise BotModeError(f"Your Pokémon cannot battle due to {reason}, switching to manual mode...") + + def _handle_flee( + self, battle_state: BattleState, util: BattleStrategyUtil, reason: str + ) -> tuple["TurnAction", Optional[any]]: + """ + Handles the flee action when the lead Pokémon cannot battle. + """ + if battle_state.is_trainer_battle: + raise BotModeError( + f"Your Pokémon cannot battle due to {reason}, and 'flee' is not allowed in a trainer battle." + ) + + best_escape_method = util.get_best_escape_method() + if best_escape_method is not None: + return best_escape_method + + raise BotModeError(f"Your Pokémon cannot battle due to {reason}, and escape is not possible.") + + def _handle_rotate(self, battle_state: BattleState, util: BattleStrategyUtil) -> tuple["TurnAction", Optional[any]]: + """ + Handles the rotate action when the lead Pokémon cannot battle. + """ + if len(util.get_potential_rotation_targets(battle_state)) > 0 and util.can_switch(): + return TurnAction.rotate_lead(util.select_rotation_target(battle_state)) + + raise BotModeError( + "Your Pokémon cannot battle, 'lead_cannot_battle_action' is set to 'rotate', but no eligible Pokémon are available for switching." + ) diff --git a/modules/battle_strategies/level_balancing.py b/modules/battle_strategies/level_balancing.py index 621785aa7..70a38b864 100644 --- a/modules/battle_strategies/level_balancing.py +++ b/modules/battle_strategies/level_balancing.py @@ -1,6 +1,5 @@ from modules.battle_state import BattleState from modules.battle_strategies import DefaultBattleStrategy, TurnAction, BattleStrategyUtil -from modules.context import context from modules.pokemon import get_party @@ -31,7 +30,7 @@ def decide_turn(self, battle_state: BattleState) -> tuple["TurnAction", any]: # in the most powerful Pokémon in the party to defeat the opponent, so that the # lead Pokémon at least gets partial XP. # This helps if the lead has a much lower level than the encounters. - if battler.party_index == 0 and not self._pokemon_has_enough_hp(battler): + if battler.party_index == 0 and not super()._pokemon_has_enough_hp(battler): strongest_pokemon: tuple[int, int] = (0, 0) party = get_party() for index in range(len(party)): diff --git a/modules/battle_strategies/level_up.py b/modules/battle_strategies/level_up.py new file mode 100644 index 000000000..3eeda958a --- /dev/null +++ b/modules/battle_strategies/level_up.py @@ -0,0 +1,84 @@ +from modules.battle_state import BattleState +from modules.battle_strategies import DefaultBattleStrategy, TurnAction, BattleStrategyUtil +from modules.context import context +from modules.pokemon import Pokemon, get_party, StatusCondition +from modules.modes import BotModeError + + +class LevelUpLeadBattleStrategy(DefaultBattleStrategy): + + def choose_new_lead_after_battle(self) -> int | None: + return None + + def decide_turn(self, battle_state: BattleState) -> tuple["TurnAction", any]: + """ + Decides the turn's action based on the current battle state. + """ + util = BattleStrategyUtil(battle_state) + current_battler = battle_state.own_side.active_battler + + def handle_lead_cannot_battle() -> tuple["TurnAction", any]: + action = context.config.battle.lead_cannot_battle_action + if action == "flee": + return self._escape(battle_state) + elif action == "rotate" and util.can_switch(): + if len(util.get_potential_rotation_targets(battle_state)) > 0: + return TurnAction.rotate_lead(util.select_rotation_target(battle_state)) + return self._escape(battle_state) + + if not any(util.move_is_usable(move) for move in current_battler.moves) or not super()._pokemon_has_enough_hp( + current_battler + ): + return handle_lead_cannot_battle() + + strongest_move = util.get_strongest_move_against( + battle_state.own_side.active_battler, battle_state.opponent.active_battler + ) + if strongest_move is None: + return handle_lead_cannot_battle() + return TurnAction.use_move(strongest_move) + + def choose_new_lead_after_faint(self, battle_state: BattleState) -> int: + new_lead: int | None = None + new_lead_current_hp: int = 0 + for index, pokemon in enumerate(get_party()): + if pokemon.current_hp > 0 and not pokemon.is_egg and self.pokemon_can_battle(pokemon): + if pokemon.current_hp > new_lead_current_hp: + new_lead = index + new_lead_current_hp = pokemon.current_hp + if new_lead is not None: + return new_lead + else: + return super().choose_new_lead_after_faint(battle_state) + + def party_can_battle(self) -> bool: + party = get_party() + + for pokemon in party: + if pokemon.is_egg or pokemon.is_empty: + continue + + if super()._pokemon_has_enough_hp(pokemon) and pokemon.status_condition is StatusCondition.Healthy: + for move in pokemon.moves: + if ( + move is not None + and move.move.base_power > 0 + and move.pp > 0 + and move.move.name not in context.config.battle.banned_moves + ): + return True + return False + + def pokemon_can_battle(self, pokemon: Pokemon) -> bool: + return any( + move is not None and move.move.base_power > 0 and move.move.name not in context.config.battle.banned_moves + for move in pokemon.moves + ) + + def _escape(self, battle_state: BattleState): + util = BattleStrategyUtil(battle_state) + best_escape_method = util.get_best_escape_method() + + if best_escape_method is not None: + return best_escape_method + return TurnAction.run_away() diff --git a/modules/battle_strategies/run_away.py b/modules/battle_strategies/run_away.py index e237b5e3d..088db62df 100644 --- a/modules/battle_strategies/run_away.py +++ b/modules/battle_strategies/run_away.py @@ -20,24 +20,19 @@ def decide_turn(self, battle_state: BattleState) -> tuple["TurnAction", any]: if best_escape_method is not None: return best_escape_method else: - return TurnAction.use_move( - util.get_strongest_move_against( - battle_state.own_side.active_battler, battle_state.opponent.active_battler - ) + strongest_move = util.get_strongest_move_against( + battle_state.own_side.active_battler, battle_state.opponent.active_battler ) - def decide_turn_in_double_battle(self, battle_state: BattleState, battler_index: int) -> tuple["TurnAction", any]: - util = BattleStrategyUtil(battle_state) - battler = battle_state.own_side.left_battler if battler_index == 0 else battle_state.own_side.right_battler - best_escape_method = util.get_best_escape_method() - if best_escape_method is not None: - return best_escape_method - elif battle_state.opponent.left_battler is not None: - opponent = battle_state.opponent.left_battler - return TurnAction.use_move_against_left_side_opponent(util.get_strongest_move_against(battler, opponent)) - else: - opponent = battle_state.opponent.right_battler - return TurnAction.use_move_against_right_side_opponent(util.get_strongest_move_against(battler, opponent)) + if strongest_move is not None: + return TurnAction.use_move( + util.get_strongest_move_against( + battle_state.own_side.active_battler, battle_state.opponent.active_battler + ) + ) + else: + # Even if escape chance is 0, maybe we can escape next turn + return TurnAction.run_away() def decide_turn_in_safari_zone(self, battle_state: BattleState) -> tuple["SafariTurnAction", any]: return SafariTurnAction.run_away() diff --git a/modules/gui/multi_select_window.py b/modules/gui/multi_select_window.py index 3cea7b7d8..28cfb82ae 100644 --- a/modules/gui/multi_select_window.py +++ b/modules/gui/multi_select_window.py @@ -90,3 +90,64 @@ def return_selection(value: str): time.sleep(1 / 60) return selected_value + + +def ask_for_confirmation(message: str, window_title: str = "Confirmation") -> bool | None: + """ + Displays a confirmation window with the given message and Yes/No buttons. + + Parameters: + message (str): The message to display in the confirmation window. + window_title (str): The title of the window (default: "Confirmation"). + + Returns: + bool | None: True if 'Yes' is selected, False if 'No' is selected, or None if the window is closed. + """ + if context.gui.is_headless: + response = Prompt.ask(message, choices=["Yes", "No"]) + return response == "Yes" + + window = Toplevel(context.gui.window) + user_choice: bool | None = None + + def on_yes(): + nonlocal user_choice + user_choice = True + window.after(50, remove_window) + + def on_no(): + nonlocal user_choice + user_choice = False + window.after(50, remove_window) + + def remove_window(event=None): + nonlocal window + window.destroy() + window = None + + window.title(window_title) + window.geometry("400x180") + window.protocol("WM_DELETE_WINDOW", remove_window) + window.bind("", remove_window) + window.rowconfigure(0, weight=1) + window.columnconfigure(0, weight=1) + + frame = ttk.Frame(window, padding=10) + frame.pack(fill="both", expand=True) + + label = ttk.Label(frame, text=message, anchor="center", wraplength=250) + label.pack(pady=20) + + button_frame = ttk.Frame(frame) + button_frame.pack() + yes_button = ttk.Button(button_frame, text="Yes", command=on_yes) + yes_button.grid(row=0, column=0, padx=10) + no_button = ttk.Button(button_frame, text="No", command=on_no) + no_button.grid(row=0, column=1, padx=10) + + while window is not None: + window.update_idletasks() + window.update() + time.sleep(1 / 60) + + return user_choice diff --git a/modules/modes/_asserts.py b/modules/modes/_asserts.py index 2cff9a258..6ddfd053b 100644 --- a/modules/modes/_asserts.py +++ b/modules/modes/_asserts.py @@ -178,15 +178,25 @@ def assert_player_has_poke_balls() -> None: raise BotModeError("Out of Pokéballs! Better grab more before the next shiny slips away...") -def assert_pokemon_can_fight(pokemon: Pokemon) -> None: +def is_pokemon_able_to_fight(pokemon: Pokemon) -> bool: """ - Ensures the given Pokémon has at least one usable attacking move. Raises a BotModeError - if the Pokémon lacks any attack-capable moves, indicating it cannot kill an opponent. + Checks if the given Pokémon has at least one usable attacking move. + Returns True if a usable move is found; otherwise, False. """ - has_usable_move = any( + return any( move is not None and move.move.base_power > 0 and move.move.name not in context.config.battle.banned_moves for move in pokemon.moves ) - if not has_usable_move: - raise BotModeError("Lead Pokémon has no usable moves!") + +def assert_party_can_fight(error_message: str, check_in_saved_game: bool = False) -> None: + """ + Ensures the party has at least one Pokémon with a usable attacking move. + Raises a BotModeError if no Pokémon has any attack-capable moves. + """ + party = get_party() if not check_in_saved_game else get_save_data().get_party() + + if any(not pokemon.is_egg and not pokemon.is_empty and is_pokemon_able_to_fight(pokemon) for pokemon in party): + return + + raise BotModeError(error_message) diff --git a/modules/modes/level_grind.py b/modules/modes/level_grind.py index 737c0b9fe..ae1c4fef3 100644 --- a/modules/modes/level_grind.py +++ b/modules/modes/level_grind.py @@ -7,14 +7,15 @@ from modules.modes import BattleAction from modules.player import get_player_avatar from modules.pokemon import get_party, StatusCondition -from ._asserts import assert_pokemon_can_fight +from ._asserts import assert_party_can_fight from ._interface import BotMode, BotModeError from .util import navigate_to, heal_in_pokemon_center, change_lead_party_pokemon, spin from ..battle_state import BattleOutcome from ..battle_strategies import BattleStrategy, DefaultBattleStrategy from ..battle_strategies.level_balancing import LevelBalancingBattleStrategy +from ..battle_strategies.level_up import LevelUpLeadBattleStrategy from ..encounter import handle_encounter, EncounterInfo -from ..gui.multi_select_window import ask_for_choice, Selection +from ..gui.multi_select_window import ask_for_choice, Selection, ask_for_confirmation from ..runtime import get_sprites_path from ..sprites import get_sprite @@ -97,11 +98,6 @@ } -class NoRotateLeadDefaultBattleStrategy(DefaultBattleStrategy): - def choose_new_lead_after_battle(self) -> int | None: - return None - - class LevelGrindMode(BotMode): @staticmethod def name() -> str: @@ -127,39 +123,105 @@ def on_battle_started(self, encounter: EncounterInfo | None) -> BattleAction | B if self._level_balance: return LevelBalancingBattleStrategy() else: - return NoRotateLeadDefaultBattleStrategy() + return LevelUpLeadBattleStrategy() else: return action def on_battle_ended(self, outcome: "BattleOutcome") -> None: lead_pokemon = get_party()[0] - if ( - not DefaultBattleStrategy().pokemon_can_battle(lead_pokemon) - or lead_pokemon.status_condition is not StatusCondition.Healthy - ): - self._go_healing = True + + if self._level_balance: + if ( + not DefaultBattleStrategy().pokemon_can_battle(lead_pokemon) + or lead_pokemon.status_condition is not StatusCondition.Healthy + ): + self._go_healing = True + else: + if lead_pokemon.current_hp_percentage < context.config.battle.hp_threshold: + self._go_healing = True + + lead_pokemon_has_damaging_moves = False + lead_pokemon_has_damaging_move_with_pp = False + for learned_move in lead_pokemon.moves: + if ( + learned_move is not None + and learned_move.move.base_power > 1 + and learned_move.move.name not in context.config.battle.banned_moves + ): + lead_pokemon_has_damaging_moves = True + if learned_move.pp > 0: + lead_pokemon_has_damaging_move_with_pp = True + if lead_pokemon_has_damaging_moves and not lead_pokemon_has_damaging_move_with_pp: + self._go_healing = True + + if not LevelUpLeadBattleStrategy().party_can_battle(): + self._go_healing = True def on_whiteout(self) -> bool: self._leave_pokemon_center = True return True def run(self) -> Generator: + # Check training spot first to see if it has encounters to not print multi choices windows for nothing + training_spot = self._get_training_spot() + + party_lead_pokemon, party_lead_index = self._get_party_lead() + level_mode_choice = self._ask_for_leveling_mode(party_lead_pokemon) + + if level_mode_choice is None: + context.set_manual_mode() + yield + return + + if level_mode_choice.startswith("Level-balance"): + self._level_balance = True + else: + assert_party_can_fight("No Pokémon in the party has a usable attacking move!") + + if not LevelUpLeadBattleStrategy().pokemon_can_battle(party_lead_pokemon): + user_confirmed = ask_for_confirmation( + "Your party leader has no battle moves. The bot will maybe swap with other Pokémon depending on your bot configuration, causing them to gain XP. Are you sure you want to proceed with this strategy?" + ) + + if not user_confirmed: + context.set_manual_mode() + yield + return + + if context.config.battle.lead_cannot_battle_action == "flee": + raise BotModeError( + "Cannot level grind because your leader has no battle moves and lead_cannot_battle_action is set to flee!" + ) + + if user_confirmed: + self._level_balance = False + else: + context.set_manual_mode() + + if self._level_balance: + party_lead_index = LevelBalancingBattleStrategy().choose_new_lead_after_battle() + + if party_lead_index: + yield from change_lead_party_pokemon(party_lead_index) + + pokemon_center = self._find_closest_pokemon_center(training_spot) + + yield from self._leveling_loop(training_spot, pokemon_center) + + def _get_training_spot(self): training_spot = get_map_data_for_current_position() if not training_spot.has_encounters: raise BotModeError("There are no encounters on this tile.") + return training_spot - # The first member of the party might be an egg, in which case we want to use the - # first available Pokémon as lead instead. - party_lead_pokemon = None - party_lead_index = 0 - for index in range(len(get_party())): - pokemon = get_party()[index] + def _get_party_lead(self): + for index, pokemon in enumerate(get_party()): if not pokemon.is_egg: - party_lead_pokemon = pokemon - party_lead_index = index - break + return pokemon, index + raise BotModeError("No valid Pokémon found in the party.") - level_mode_choice = ask_for_choice( + def _ask_for_leveling_mode(self, party_lead_pokemon): + return ask_for_choice( [ Selection( f"Level only first one\nin party ({party_lead_pokemon.species_name_for_stats})", @@ -170,39 +232,16 @@ def run(self) -> Generator: "What to level?", ) - if level_mode_choice is None: - context.set_manual_mode() - yield - return - elif level_mode_choice.startswith("Level-balance"): - self._level_balance = True - else: - assert_pokemon_can_fight(party_lead_pokemon) - self._level_balance = False - - if self._level_balance: - party_lead_index = LevelBalancingBattleStrategy().choose_new_lead_after_battle() - - if party_lead_index: - yield from change_lead_party_pokemon(party_lead_index) - + def _find_closest_pokemon_center(self, training_spot): training_spot_map = get_map_enum(training_spot) - training_spot_coordinates = training_spot.local_position - - # Find the closest Pokemon Center to the current location pokemon_center = None path_length_to_pokemon_center = None + if training_spot_map in closest_pokemon_centers: for pokemon_center_candidate in closest_pokemon_centers[training_spot_map]: try: - pokemon_center_location = get_map_data( - pokemon_center_candidate.value[0], pokemon_center_candidate.value[1] - ) - path_to = calculate_path(training_spot, pokemon_center_location) - path_from = [] - path_length = len(path_to) + len(path_from) - - if path_length_to_pokemon_center is None or path_length_to_pokemon_center > path_length: + path_length = self._calculate_path_length(training_spot, pokemon_center_candidate) + if path_length_to_pokemon_center is None or path_length < path_length_to_pokemon_center: pokemon_center = pokemon_center_candidate path_length_to_pokemon_center = path_length except PathFindingError: @@ -211,6 +250,18 @@ def run(self) -> Generator: if pokemon_center is None: raise BotModeError("Could not find a suitable from here to a Pokemon Center nearby.") + return pokemon_center + + def _calculate_path_length(self, training_spot, pokemon_center_candidate) -> int: + center_location = get_map_data(*pokemon_center_candidate.value) + path_to = calculate_path(training_spot, center_location) + path_from = [] + return len(path_to) + len(path_from) + + def _leveling_loop(self, training_spot, pokemon_center): + training_spot_map = get_map_enum(training_spot) + training_spot_coordinates = training_spot.local_position + while True: if self._leave_pokemon_center: yield from navigate_to(get_player_avatar().map_group_and_number, (7, 8))