Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
run: |
pip install --upgrade pip
pip install robotframework robotframework-pythonlibcore textual rich \
mypy ruff pytest pytest-cov
mypy ruff pytest pytest-cov pytest-asyncio

- name: Format check (ruff format)
run: ruff format --check PokemonLibrary/ tests/
Expand Down
153 changes: 114 additions & 39 deletions PokemonLibrary/battle/battle_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,26 +436,30 @@ def attempt_flee(
handle_pokemon_fainted_callback(output)


def attempt_catch_pokemon(
def _begin_catch_attempt(
game_state: "GameState",
output: RichLog,
pending_command_callback,
show_battle_options_callback,
end_battle_callback,
handle_pokemon_fainted_callback,
ball_type: str = "Pokeball",
) -> None:
) -> Optional[tuple]: # (caught, shakes, wild_dict, messages)
"""
Attempt to catch the wild Pokemon with a Pokeball.
Validate a catch attempt, consume the ball, and compute the shake result.

This is the first phase of the catch sequence used by the animated mixin
method. Handles error cases (trainer battle, no balls) and returns the
catch result tuple for the caller to animate and finalise.

Args:
game_state: The game state
output: The RichLog widget to write to
pending_command_callback: Callback to set pending command
show_battle_options_callback: Callback to show battle options
end_battle_callback: Callback to end battle
handle_pokemon_fainted_callback: Callback to handle Pokemon fainted
ball_type: Type of ball to use (Pokeball, Great Ball, Ultra Ball, Master Ball)
ball_type: Type of ball to use

Returns:
``(caught, shakes, wild, messages)`` on success, or ``None`` if an
early-exit error was already written to *output*.
"""
battle = game_state.battle_state

Expand All @@ -468,13 +472,11 @@ def attempt_catch_pokemon(
output.write("")
show_battle_options_callback(output)
pending_command_callback("battle")
return
return None

items = game_state.game_data.get("items", {})

# Check inventory for the chosen ball type; fall back to any available ball
if items.get(ball_type, 0) <= 0:
# Try to find any available ball to inform the player
output.write("")
if ball_type == "Pokeball":
output.write("[red]❌ You have no Pokeballs![/red]")
Expand All @@ -485,7 +487,7 @@ def attempt_catch_pokemon(
output.write("")
show_battle_options_callback(output)
pending_command_callback("battle")
return
return None

wild = battle.wild_pokemon

Expand All @@ -498,20 +500,45 @@ def attempt_catch_pokemon(
output.write(f"[bold cyan]You threw a {ball_type} at wild {wild['name']}![/bold cyan]")
output.write("")

# Attempt catch
caught, shakes, messages = battle.attempt_catch(ball_type)
return caught, shakes, wild, messages

# Show custom messages from catch attempt
for msg in messages:
output.write(msg)

# Show wiggle animation
wiggle_text = "● " * shakes + "○ " * (4 - shakes)
output.write(f"[dim]{wiggle_text.strip()}[/dim]")
output.write("")
def _finish_catch_attempt(
game_state: "GameState",
output: RichLog,
caught: bool,
shakes: int,
wild: dict,
pending_command_callback,
show_battle_options_callback,
end_battle_callback,
handle_pokemon_fainted_callback,
already_announced: bool = False,
) -> None:
"""
Finalise a catch attempt after the shake animation has been shown.

Handles adding the Pokemon to the party (or PC), Pokédex registration,
and failed-catch aftermath.

Args:
game_state: The game state
output: The RichLog widget to write to
caught: Whether the Pokemon was caught
shakes: Number of successful shakes before breaking free
wild: The wild Pokemon dict
pending_command_callback: Callback to set pending command
show_battle_options_callback: Callback to show battle options
end_battle_callback: Callback to end the battle
handle_pokemon_fainted_callback: Callback to handle player Pokemon fainting
already_announced: When True the catch/escape announcement was already
written by the caller (e.g. via ``progressive_reveal``); skip writing
it again to avoid duplicates.
"""
battle = game_state.battle_state

if caught:
# Successful catch!
pokemon_party = game_state.game_data.get("pokemon", [])

if len(pokemon_party) >= 6:
Expand All @@ -533,7 +560,8 @@ def attempt_catch_pokemon(
}
placed_box = pc_system.send_to_pc(game_state, caught_pokemon)
if placed_box:
output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]")
if not already_announced:
output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]")
output.write(
f"[yellow] Your party is full — {wild['name']} was sent to {placed_box}![/yellow]"
)
Expand All @@ -549,13 +577,12 @@ def attempt_catch_pokemon(
if type_msg:
output.write(f"[yellow]{type_msg}[/yellow]")
else:
output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]")
if not already_announced:
output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]")
output.write(
f"[red] Your party and PC are both full! {wild['name']} could not be stored.[/red]"
)
else:
# Add to party
# Remove battle-specific fields and prepare for party
caught_pokemon = {
"name": wild["name"],
"number": wild.get("number", 0),
Expand All @@ -571,9 +598,9 @@ def attempt_catch_pokemon(
"no_evolve": False,
}
pokemon_party.append(caught_pokemon)
output.write(f"[bold green]★ Gotcha! {wild['name']} was caught! ★[/bold green]")
if not already_announced:
output.write(f"[bold green]★ Gotcha! {wild['name']} was caught! ★[/bold green]")

# Mark as caught in Pokedex
species = wild.get("species", wild["name"]).upper()
if pokedex.mark_as_caught(game_state, species):
output.write(f"[dim]📖 Pokedex: {wild['name']} was registered as caught![/dim]")
Expand All @@ -588,32 +615,80 @@ def attempt_catch_pokemon(
output.write(f"[green]✓ {wild['name']} was added to your party![/green]")

output.write("")
# End battle on successful catch
_stats.record_catch(game_state, wild.get("species", wild["name"]))
end_battle_callback(output)
else:
# Failed catch
if shakes == 0:
output.write(f"[yellow]Oh no! {wild['name']} broke free immediately![/yellow]")
elif shakes == 1:
output.write("[yellow]Aww! It appeared to be caught![/yellow]")
elif shakes == 2:
output.write("[yellow]Aargh! Almost had it![/yellow]")
elif shakes == 3:
output.write("[yellow]Gah! It was so close, too![/yellow]")
if not already_announced:
if shakes == 0:
output.write(f"[yellow]Oh no! {wild['name']} broke free immediately![/yellow]")
elif shakes == 1:
output.write("[yellow]Aww! It appeared to be caught![/yellow]")
elif shakes == 2:
output.write("[yellow]Aargh! Almost had it![/yellow]")
elif shakes == 3:
output.write("[yellow]Gah! It was so close, too![/yellow]")
output.write("")

# Wild Pokemon gets a free turn
execute_wild_pokemon_turn(game_state, output)

# Check if player fainted
if battle.player_pokemon["hp"] <= 0:
handle_pokemon_fainted_callback(output)
else:
show_battle_options_callback(output)
pending_command_callback("battle")


def attempt_catch_pokemon(
game_state: "GameState",
output: RichLog,
pending_command_callback,
show_battle_options_callback,
end_battle_callback,
handle_pokemon_fainted_callback,
ball_type: str = "Pokeball",
) -> None:
"""
Attempt to catch the wild Pokemon with a Pokeball.

Args:
game_state: The game state
output: The RichLog widget to write to
pending_command_callback: Callback to set pending command
show_battle_options_callback: Callback to show battle options
end_battle_callback: Callback to end battle
handle_pokemon_fainted_callback: Callback to handle Pokemon fainted
ball_type: Type of ball to use (Pokeball, Great Ball, Ultra Ball, Master Ball)
"""
result = _begin_catch_attempt(
game_state, output, pending_command_callback, show_battle_options_callback, ball_type
)
if result is None:
return

caught, shakes, wild, messages = result

# Show custom messages from catch attempt
for msg in messages:
output.write(msg)

# Show synchronous wiggle animation (animated version lives in BattleMixin)
wiggle_text = "● " * shakes + "○ " * (4 - shakes)
output.write(f"[dim]{wiggle_text.strip()}[/dim]")
output.write("")

_finish_catch_attempt(
game_state,
output,
caught,
shakes,
wild,
pending_command_callback,
show_battle_options_callback,
end_battle_callback,
handle_pokemon_fainted_callback,
)


def execute_switch(
game_state: "GameState",
target: str,
Expand Down
17 changes: 11 additions & 6 deletions PokemonLibrary/buildings.py
Original file line number Diff line number Diff line change
Expand Up @@ -907,14 +907,16 @@ def enter_players_house(
output.write("")


def perform_pokemon_center_heal(game_state: "GameState", output: RichLog) -> None:
async def perform_pokemon_center_heal(game_state: "GameState", output: RichLog) -> None:
"""
Actually perform the Pokemon Center healing.
Actually perform the Pokemon Center healing with animated reveals.

Args:
game_state: The game state object
output: The RichLog widget to write to
"""
from .ui.text_animation import reveal_lines

pokemon = game_state.game_data.get("pokemon", [])

# Record this as the last visited Pokemon Center
Expand All @@ -928,7 +930,8 @@ def perform_pokemon_center_heal(game_state: "GameState", output: RichLog) -> Non
output.write("[cyan] ♪ Healing sound ♪[/cyan]")
output.write("")

# Show healed Pokemon and restore HP + PP + status
# Build per-Pokemon healing lines and restore stats up front
heal_lines = []
for p in pokemon:
if not isinstance(p, str):
p["hp"] = p.get("max_hp", p["hp"])
Expand All @@ -938,11 +941,13 @@ def perform_pokemon_center_heal(game_state: "GameState", output: RichLog) -> Non
p["status"] = None
name = p["name"]
if had_status:
output.write(f" [green]✓ {name} restored to full health and cured![/green]")
heal_lines.append(f" [green]✓ {name} restored to full health and cured![/green]")
else:
output.write(f" [green]✓ {name} restored to full health![/green]")
heal_lines.append(f" [green]✓ {name} restored to full health![/green]")
else:
output.write(f" [green]✓ {p} restored to full health![/green]")
heal_lines.append(f" [green]✓ {p} restored to full health![/green]")

await reveal_lines(output, heal_lines, delay=0.5)

output.write("")
output.write("[bold]Nurse Joy:[/bold] [magenta]Your Pokemon are now fully healed![/magenta]")
Expand Down
6 changes: 3 additions & 3 deletions PokemonLibrary/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ def _refresh_subtitle(self) -> None:

# ── Event handlers ───────────────────────────────────────────────────────

def on_button_pressed(self, event: Button.Pressed) -> None:
async def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
button_id = event.button.id
output = self.query_one("#output", RichLog)
Expand Down Expand Up @@ -570,7 +570,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
elif button_id.startswith("btn-move-"):
if button_id == "btn-move-back":
output.write("[bold yellow]🎮 >[/bold yellow] Back")
self.execute_player_move("back", output)
await self.execute_player_move("back", output)
else:
move_index = int(button_id.split("-")[-1])
if self.game_state.battle_state:
Expand All @@ -579,7 +579,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
if move_index < len(moves):
move_name = moves[move_index]["name"]
output.write(f"[bold yellow]🎮 >[/bold yellow] {move_name}")
self.execute_player_move(move_name, output)
await self.execute_player_move(move_name, output)

# Starter Pokemon selection buttons
elif button_id.startswith("btn-starter-"):
Expand Down
Loading