diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b1a4a54..21bc8693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ --- +#### New Effects (0.16.0) + +--- + +* Added Fishing, where concurrent hooks catch scattered swimming characters and reel them into their final text + positions, with reusable fishing lines, bite ripples, and bounded optional junk catches. + #### Development Tooling (0.16.0) --- diff --git a/README.md b/README.md index e0930a27..f0bbfc53 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ View the [Documentation](https://chrisbuilds.github.io/terminaltexteffects/) for Effect: Name of the effect to apply. Use -h for effect specific help. - {beams,binarypath,blackhole,bouncyballs,bubbles,burn,colorshift,crumble,decrypt,errorcorrect,expand,fireworks,highlight,laseretch,matrix,middleout,orbittingvolley,overflow,pour,print,rain,randomsequence,rings,scattered,slice,slide,smoke,spotlights,spray,swarm,sweep,synthgrid,thunderstorm,unstable,vhstape,waves,wipe} + {beams,binarypath,blackhole,bouncyballs,bubbles,burn,colorshift,crumble,decrypt,errorcorrect,expand,fireworks,fishing,highlight,laseretch,matrix,middleout,orbittingvolley,overflow,pour,print,rain,randomsequence,rings,scattered,slice,slide,smoke,spotlights,spray,swarm,sweep,synthgrid,thunderstorm,unstable,vhstape,waves,wipe} Available Effects beams Create beams which travel over the canvas illuminating the characters behind them. binarypath Binary representations of each character move towards the home coordinate of the character. @@ -203,6 +203,7 @@ View the [Documentation](https://chrisbuilds.github.io/terminaltexteffects/) for errorcorrect Some characters start in the wrong position and are corrected in sequence. expand Expands the text from a single point. fireworks Characters launch and explode like fireworks and fall into place. + fishing Fishing hooks catch scattered characters and reel them into place. highlight Run a specular highlight across the text. laseretch A laser etches characters onto the terminal. matrix Matrix digital rain effect. diff --git a/docs/effects/fishing.md b/docs/effects/fishing.md new file mode 100644 index 00000000..cdf2671c --- /dev/null +++ b/docs/effects/fishing.md @@ -0,0 +1,18 @@ +# Fishing + +Several fishing lines cast from the top of the canvas, catch scattered swimming characters, and reel them into their +correct final text positions. Hooks briefly tug at each catch, transport it across the canvas, and cleanly disappear +after the text settles. Each hook also has a small, bounded chance to catch harmless junk before retrying its target. + +## Quick Start + +``` py title="fishing.py" +from terminaltexteffects.effects import Fishing + +effect = Fishing("YourTextHere") +with effect.terminal_output() as terminal: + for frame in effect: + terminal.print(frame) +``` + +::: terminaltexteffects.effects.effect_fishing diff --git a/docs/showroom.md b/docs/showroom.md index 9848f783..dffae633 100644 --- a/docs/showroom.md +++ b/docs/showroom.md @@ -500,6 +500,40 @@ Launches characters up the screen where they explode like fireworks and fall int ``` --- +## Fishing + +Fishing hooks catch scattered, swimming characters and reel them into their correct text positions. + +[Reference](./effects/fishing.md){ .md-button } [Config](./effects/fishing.md#terminaltexteffects.effects.effect_fishing.FishingConfig){ .md-button } + +??? example "Fishing Command Line Arguments" + + ``` + --hook-count (int > 0) + Maximum number of fishing hooks working concurrently. (default: 3) + --line-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) + Color used for fishing lines and hooks. (default: D6F6FF) + --water-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] + Colors used while input characters swim before being caught. (default: 0B7285 1098AD 66D9E8) + --cast-speed (float > 0) + Speed at which hooks slide and cast toward swimming characters. (default: 0.75) + --reel-speed (float > 0) + Speed used while reeling catches and returning hooks. (default: 1.25) + --cast-delay (int >= 0) + Frames between casts and between each hook's initial start. (default: 4) + --wrong-catch-chance (0 <= float(n) <= 1) + One-time probability that each hook catches harmless junk before a real target. (default: 0.05) + --final-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] + Final character gradient colors. (default: 1E90FF 00D1B2 FFE66D) + --final-gradient-steps (int > 0) [(int > 0) ...] + Number of steps in the final gradient. (default: 12) + --final-gradient-direction (diagonal, horizontal, vertical, radial) + Direction of the final gradient across the text. (default: horizontal) + + Example: terminaltexteffects fishing --hook-count 3 --line-color D6F6FF --water-colors 0B7285 1098AD 66D9E8 --cast-speed 0.75 --reel-speed 1.25 --cast-delay 4 --wrong-catch-chance 0.05 --final-gradient-stops 1E90FF 00D1B2 FFE66D --final-gradient-steps 12 --final-gradient-direction horizontal + ``` +--- + ## Highlight Run a specular highlight across the text. diff --git a/mkdocs.yml b/mkdocs.yml index 48033045..8ab0c46b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -111,6 +111,7 @@ nav: - effects/errorcorrect.md - effects/expand.md - effects/fireworks.md + - effects/fishing.md - effects/highlight.md - effects/laseretch.md - effects/matrix.md diff --git a/terminaltexteffects/effects/__init__.py b/terminaltexteffects/effects/__init__.py index a30224a6..86e082f0 100644 --- a/terminaltexteffects/effects/__init__.py +++ b/terminaltexteffects/effects/__init__.py @@ -12,6 +12,7 @@ from terminaltexteffects.effects.effect_errorcorrect import ErrorCorrect from terminaltexteffects.effects.effect_expand import Expand from terminaltexteffects.effects.effect_fireworks import Fireworks +from terminaltexteffects.effects.effect_fishing import Fishing from terminaltexteffects.effects.effect_highlight import Highlight from terminaltexteffects.effects.effect_laseretch import LaserEtch from terminaltexteffects.effects.effect_matrix import Matrix diff --git a/terminaltexteffects/effects/effect_fishing.py b/terminaltexteffects/effects/effect_fishing.py new file mode 100644 index 00000000..d8cb0abf --- /dev/null +++ b/terminaltexteffects/effects/effect_fishing.py @@ -0,0 +1,637 @@ +"""Catch scattered input characters with fishing hooks and place them into position.""" + +from __future__ import annotations + +import random +from collections import deque +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import TYPE_CHECKING + +from terminaltexteffects.engine.base_config import ( + BaseConfig, + FinalGradientDirectionArg, + FinalGradientStepsArg, + FinalGradientStopsArg, +) +from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator +from terminaltexteffects.engine.effect_support import ParticlePool, ParticleReset +from terminaltexteffects.utils import argutils, easing +from terminaltexteffects.utils.geometry import Coord +from terminaltexteffects.utils.graphics import Color, ColorPair, Gradient + +if TYPE_CHECKING: + from terminaltexteffects.engine.base_character import EffectCharacter + + +def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: + """Return the command, effect class, and config class for discovery.""" + return "fishing", Fishing, FishingConfig + + +@dataclass +class FishingConfig(BaseConfig): + """Configuration for the Fishing effect.""" + + parser_spec: argutils.ParserSpec = argutils.ParserSpec( + name="fishing", + help="Fishing hooks catch scattered characters and reel them into place.", + description="fishing | Fishing hooks catch scattered characters and reel them into place.", + epilog=( + "Example: terminaltexteffects fishing --hook-count 3 --line-color D6F6FF " + "--water-colors 0B7285 1098AD 66D9E8 --cast-speed 0.75 --reel-speed 1.25 " + "--cast-delay 4 --wrong-catch-chance 0.05 --final-gradient-stops 1E90FF 00D1B2 FFE66D " + "--final-gradient-steps 12 --final-gradient-direction horizontal" + ), + ) + + hook_count: int = argutils.ArgSpec( + name="--hook-count", + type=argutils.PositiveInt.type_parser, + default=3, + metavar=argutils.PositiveInt.METAVAR, + help="Maximum number of fishing hooks working concurrently.", + ) # pyright: ignore[reportAssignmentType] + line_color: Color = argutils.ArgSpec( + name="--line-color", + type=argutils.ColorArg.type_parser, + default=Color("#D6F6FF"), + metavar=argutils.ColorArg.METAVAR, + help="Color used for fishing lines and hooks.", + ) # pyright: ignore[reportAssignmentType] + water_colors: tuple[Color, ...] = argutils.ArgSpec( + name="--water-colors", + type=argutils.ColorArg.type_parser, + nargs="+", + action=argutils.TupleAction, + default=(Color("#0B7285"), Color("#1098AD"), Color("#66D9E8")), + metavar=argutils.ColorArg.METAVAR, + help="Colors used while input characters swim before being caught.", + ) # pyright: ignore[reportAssignmentType] + cast_speed: float = argutils.ArgSpec( + name="--cast-speed", + type=argutils.PositiveFloat.type_parser, + default=0.75, + metavar=argutils.PositiveFloat.METAVAR, + help="Speed at which hooks slide and cast toward swimming characters.", + ) # pyright: ignore[reportAssignmentType] + reel_speed: float = argutils.ArgSpec( + name="--reel-speed", + type=argutils.PositiveFloat.type_parser, + default=1.25, + metavar=argutils.PositiveFloat.METAVAR, + help="Speed used while reeling catches and returning hooks.", + ) # pyright: ignore[reportAssignmentType] + cast_delay: int = argutils.ArgSpec( + name="--cast-delay", + type=argutils.NonNegativeInt.type_parser, + default=4, + metavar=argutils.NonNegativeInt.METAVAR, + help="Frames between casts and between each hook's initial start.", + ) # pyright: ignore[reportAssignmentType] + wrong_catch_chance: float = argutils.ArgSpec( + name="--wrong-catch-chance", + type=argutils.NonNegativeRatio.type_parser, + default=0.05, + metavar=argutils.NonNegativeRatio.METAVAR, + help="One-time probability that each hook catches harmless junk before a real target.", + ) # pyright: ignore[reportAssignmentType] + final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( + default=(Color("#1E90FF"), Color("#00D1B2"), Color("#FFE66D")), + ) # pyright: ignore[reportAssignmentType] + final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg(default=12) # pyright: ignore[reportAssignmentType] + final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( + default=Gradient.Direction.HORIZONTAL, + ) # pyright: ignore[reportAssignmentType] + + +class HookPhase(Enum): + """Current lifecycle phase for one fishing hook.""" + + WAITING = auto() + CASTING = auto() + BITING = auto() + REELING = auto() + TRANSPORTING = auto() + LOWERING = auto() + RELEASING = auto() + WRONG_CATCH = auto() + RETURNING = auto() + FINISHED = auto() + + +@dataclass +class HookState: + """State owned by one independently progressing fishing hook.""" + + home_column: int + hook_character: EffectCharacter + line_characters: list[EffectCharacter] + assignments: deque[EffectCharacter] = field(default_factory=deque) + phase: HookPhase = HookPhase.WAITING + target: EffectCharacter | None = None + delay: int = 0 + path_count: int = 0 + bite_ticks_remaining: int = 0 + bite_origin: Coord | None = None + caught_character: EffectCharacter | None = None + release_ticks_remaining: int = 0 + wrong_catch_pending: bool = False + wrong_catches_used: int = 0 + junk_character: EffectCharacter | None = None + casting_junk: bool = False + wrong_stage: str = "" + wrong_ticks_remaining: int = 0 + returning_from_wrong: bool = False + + +class FishingIterator(BaseEffectIterator[FishingConfig]): + """Iterator for the Fishing effect.""" + + BITE_FRAMES = 4 + RELEASE_FRAMES = 3 + WRONG_SHAKE_FRAMES = 4 + FINAL_HOLD_FRAMES = 6 + + def __init__(self, effect: Fishing) -> None: # noqa: PLR0915 + """Initialize the Fishing iterator.""" + super().__init__(effect) + self.catchable_characters = [ + character for character in self.terminal.get_characters() if character.input_symbol != " " + ] + final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) + final_gradient_mapping = final_gradient.build_coordinate_color_mapping( + self.terminal.canvas.text_bottom, + self.terminal.canvas.text_top, + self.terminal.canvas.text_left, + self.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} + for character in self.terminal.get_characters(): + if self.terminal.config.existing_color_handling == "dynamic": + self.character_final_color_map[character] = ColorPair( + fg=character.animation.input_fg_color, + bg=character.animation.input_bg_color, + ) + else: + self.character_final_color_map[character] = ColorPair( + fg=final_gradient_mapping[character.input_coord], + ) + hook_count = min(self.config.hook_count, len(self.catchable_characters), self.terminal.canvas.width) + if hook_count == 1: + home_columns = [self.terminal.canvas.center_column] + elif hook_count > 1: + home_columns = [ + self.terminal.canvas.left + round(index * (self.terminal.canvas.width - 1) / (hook_count - 1)) + for index in range(hook_count) + ] + else: + home_columns = [] + self.hooks: list[HookState] = [] + for hook_index, column in enumerate(home_columns): + hook_character = self.terminal.add_character("J", Coord(column, self.terminal.canvas.top)) + hook_character.animation.set_appearance("J", ColorPair(fg=self.config.line_color)) + hook_character.layer = 12 + (hook_index * 3) + self.terminal.set_character_visibility(hook_character, is_visible=True) + line_characters = [ + self.terminal.add_character("|", Coord(column, self.terminal.canvas.top)) + for _ in range(max(self.terminal.canvas.height - 1, 0)) + ] + for line_character in line_characters: + line_character.animation.set_appearance("|", ColorPair(fg=self.config.line_color)) + line_character.layer = 10 + (hook_index * 3) + self.hooks.append( + HookState( + home_column=column, + hook_character=hook_character, + line_characters=line_characters, + delay=hook_index * self.config.cast_delay, + wrong_catch_pending=random.random() < self.config.wrong_catch_chance, + ), + ) + top_row_coordinates = [ + Coord(column, self.terminal.canvas.top) + for column in range(self.terminal.canvas.left, self.terminal.canvas.right + 1) + ] + underwater_coordinates = [ + Coord(column, row) + for row in range(self.terminal.canvas.bottom, self.terminal.canvas.top) + for column in range(self.terminal.canvas.left, self.terminal.canvas.right + 1) + ] + random.shuffle(top_row_coordinates) + random.shuffle(underwater_coordinates) + available_coordinates = top_row_coordinates + underwater_coordinates + self.character_start_coord_map: dict[EffectCharacter, Coord] = {} + for character in self.catchable_characters: + start_coord = available_coordinates.pop() + if start_coord == character.input_coord and available_coordinates: + alternative_coord = available_coordinates.pop() + available_coordinates.append(start_coord) + start_coord = alternative_coord + self.character_start_coord_map[character] = start_coord + character.motion.set_coordinate(start_coord) + sorted_targets = sorted( + self.catchable_characters, + key=lambda character: (character.input_coord.column, -character.input_coord.row, character.character_id), + ) + if self.hooks: + base_size, larger_region_count = divmod(len(sorted_targets), len(self.hooks)) + target_index = 0 + for hook_index, hook in enumerate(self.hooks): + region_size = base_size + (1 if hook_index < larger_region_count else 0) + region = sorted_targets[target_index : target_index + region_size] + target_index += region_size + region.sort( + key=lambda character: ( + abs(self.character_start_coord_map[character].column - hook.home_column), + -self.character_start_coord_map[character].row, + character.character_id, + ), + ) + hook.assignments.extend(region) + self.character_water_color_map: dict[EffectCharacter, Color] = {} + swim_speed = max(min(self.config.cast_speed * 0.2, 0.25), 0.05) + for character in self.catchable_characters: + water_color = random.choice(self.config.water_colors) + self.character_water_color_map[character] = water_color + character.animation.set_appearance(character.input_symbol, ColorPair(fg=water_color)) + character.layer = 1 + self.terminal.set_character_visibility(character, is_visible=True) + start_coord = self.character_start_coord_map[character] + neighboring_coordinates = [ + Coord(start_coord.column + column_delta, start_coord.row + row_delta) + for column_delta, row_delta in ((-1, 0), (1, 0), (0, -1), (0, 1)) + if self.terminal.canvas.coord_is_in_canvas( + Coord(start_coord.column + column_delta, start_coord.row + row_delta), + ) + ] + if neighboring_coordinates: + swim_out = character.motion.new_path( + speed=swim_speed, + ease=easing.in_out_sine, + hold_time=3, + path_id="swim_out", + ) + swim_out.new_waypoint(random.choice(neighboring_coordinates)) + swim_back = character.motion.new_path( + speed=swim_speed, + ease=easing.in_out_sine, + hold_time=3, + path_id="swim_back", + ) + swim_back.new_waypoint(start_coord) + character.motion.chain_paths([swim_out, swim_back], loop=True) + character.motion.activate_path(swim_out) + self.active_characters.add(character) + + def initialize_ripple(ripple: EffectCharacter) -> None: + ripple.layer = 50 + ripple_scene = ripple.animation.new_scene(scene_id="ripple") + ripple_scene.add_frame("~", 1, colors=ColorPair(fg=self.config.water_colors[-1])) + ripple_scene.add_frame("-", 1, colors=ColorPair(fg=self.config.water_colors[-1])) + ripple_scene.add_frame(".", 1, colors=ColorPair(fg=self.config.water_colors[0])) + + self.ripple_pool = ParticlePool( + self.terminal, + self.active_characters, + "~", + initial_count=len(self.hooks), + max_size=len(self.hooks), + coord=Coord(self.terminal.canvas.left, self.terminal.canvas.bottom), + initializer=initialize_ripple, + ) + for ripple in self.ripple_pool.particles: + self.ripple_pool.reclaim_on_event(ripple, ripple.animation.query_scene("ripple")) + + def initialize_junk(junk: EffectCharacter) -> None: + junk.layer = 11 + junk.animation.set_appearance(junk.input_symbol, ColorPair(fg=self.config.water_colors[-1])) + + junk_count = len(self.hooks) if self.config.wrong_catch_chance > 0 else 0 + self.junk_pool = ParticlePool( + self.terminal, + self.active_characters, + ("?", "#", "*"), + initial_count=junk_count, + max_size=len(self.hooks), + coord=Coord(self.terminal.canvas.left, self.terminal.canvas.bottom), + initializer=initialize_junk, + ) + self._cleanup_complete = False + self._final_hold_frames_remaining = 0 + + def _sync_line(self, hook: HookState) -> None: + """Resize and reposition a hook's reusable vertical fishing line.""" + hook_coord = hook.hook_character.motion.current_coord + visible_line_length = max(self.terminal.canvas.top - hook_coord.row, 0) + for index, line_character in enumerate(hook.line_characters): + if index < visible_line_length: + line_character.motion.set_coordinate(Coord(hook_coord.column, hook_coord.row + index + 1)) + self.terminal.set_character_visibility(line_character, is_visible=True) + else: + self.terminal.set_character_visibility(line_character, is_visible=False) + + def _start_cast(self, hook: HookState) -> None: + """Select the next assigned target and cast the hook toward it.""" + if hook.target is None: + hook.target = hook.assignments.popleft() + if hook.wrong_catch_pending: + hook.junk_character = self.junk_pool.acquire( + reset=ParticleReset(clear_paths=True, clear_scenes=True, clear_events=True), + ) + assert hook.junk_character is not None + target_coord = hook.target.motion.current_coord + neighboring_coordinates = [ + Coord(target_coord.column + column_delta, target_coord.row + row_delta) + for column_delta, row_delta in ((-1, 0), (1, 0), (0, -1), (0, 1)) + if self.terminal.canvas.coord_is_in_canvas( + Coord(target_coord.column + column_delta, target_coord.row + row_delta), + ) + ] + junk_coord = random.choice(neighboring_coordinates) if neighboring_coordinates else target_coord + hook.junk_character.motion.set_coordinate(junk_coord) + hook.junk_character.animation.set_appearance( + hook.junk_character.input_symbol, + ColorPair(fg=self.config.water_colors[-1]), + ) + self.terminal.set_character_visibility(hook.junk_character, is_visible=True) + target_coord = junk_coord + hook.casting_junk = True + hook.wrong_catch_pending = False + else: + hook.target.motion.deactivate_path() + target_coord = hook.target.motion.current_coord + approach_coord = Coord(target_coord.column, min(self.terminal.canvas.top, target_coord.row + 1)) + hook.path_count += 1 + cast_path = hook.hook_character.motion.new_path( + speed=self.config.cast_speed, + ease=easing.in_out_sine, + path_id=f"cast_{hook.path_count}", + ) + top_target_coord = Coord(target_coord.column, self.terminal.canvas.top) + cast_path.new_waypoint(top_target_coord) + if approach_coord != top_target_coord: + cast_path.new_waypoint(approach_coord) + hook.hook_character.motion.activate_path(cast_path) + self.active_characters.add(hook.hook_character) + hook.phase = HookPhase.CASTING + + def _start_wrong_catch(self, hook: HookState) -> None: + """Attach auxiliary junk and reel it only a short distance.""" + assert hook.junk_character is not None + hook.caught_character = hook.junk_character + hook.path_count += 1 + wrong_reel_path = hook.hook_character.motion.new_path( + speed=self.config.reel_speed, + ease=easing.out_sine, + path_id=f"wrong_reel_{hook.path_count}", + ) + wrong_reel_path.new_waypoint( + Coord( + hook.hook_character.motion.current_coord.column, + min(self.terminal.canvas.top, hook.hook_character.motion.current_coord.row + 2), + ), + ) + hook.hook_character.motion.activate_path(wrong_reel_path) + self.active_characters.add(hook.hook_character) + hook.wrong_stage = "reeling" + hook.phase = HookPhase.WRONG_CATCH + + def _advance_wrong_catch(self, hook: HookState) -> None: + """Finish the bounded wrong-catch reel and shake before retrying the real target.""" + assert hook.junk_character is not None + if hook.wrong_stage == "reeling" and hook.hook_character.motion.active_path is None: + hook.wrong_stage = "shaking" + hook.wrong_ticks_remaining = self.WRONG_SHAKE_FRAMES + elif hook.wrong_stage == "shaking" and hook.wrong_ticks_remaining: + symbol = "!" if hook.wrong_ticks_remaining % 2 else hook.junk_character.input_symbol + hook.junk_character.animation.set_appearance(symbol, ColorPair(fg=self.config.water_colors[-1])) + hook.wrong_ticks_remaining -= 1 + elif hook.wrong_stage == "shaking": + self.junk_pool.reclaim(hook.junk_character) + hook.junk_character = None + hook.caught_character = None + hook.casting_junk = False + hook.wrong_stage = "" + hook.wrong_catches_used += 1 + self._start_returning(hook, retry_target=True) + + def _wiggle_target(self, hook: HookState) -> None: + """Move the selected target by one cell during the bite cue.""" + assert hook.target is not None + assert hook.bite_origin is not None + direction = -1 if hook.bite_ticks_remaining % 2 == 0 else 1 + horizontal_coord = Coord(hook.bite_origin.column + direction, hook.bite_origin.row) + if self.terminal.canvas.coord_is_in_canvas(horizontal_coord): + hook.target.motion.set_coordinate(horizontal_coord) + else: + vertical_coord = Coord(hook.bite_origin.column, hook.bite_origin.row + direction) + hook.target.motion.set_coordinate( + vertical_coord if self.terminal.canvas.coord_is_in_canvas(vertical_coord) else hook.bite_origin, + ) + hook.bite_ticks_remaining -= 1 + + def _start_reeling(self, hook: HookState) -> None: + """Attach the selected input character and reel it toward the travel row.""" + assert hook.target is not None + if hook.bite_origin is not None: + hook.target.motion.set_coordinate(hook.bite_origin) + hook.caught_character = hook.target + hook.caught_character.layer = hook.hook_character.layer - 1 + travel_row = max(hook.hook_character.motion.current_coord.row, self.terminal.canvas.top - 1) + hook.path_count += 1 + reel_path = hook.hook_character.motion.new_path( + speed=self.config.reel_speed, + ease=easing.out_sine, + path_id=f"reel_{hook.path_count}", + ) + reel_path.new_waypoint(Coord(hook.hook_character.motion.current_coord.column, travel_row)) + hook.hook_character.motion.activate_path(reel_path) + self.active_characters.add(hook.hook_character) + hook.phase = HookPhase.REELING + + def _emit_ripple(self, coord: Coord) -> None: + """Emit one short pooled ripple at a bite coordinate.""" + + def activate_ripple(ripple: EffectCharacter) -> None: + ripple.animation.activate_scene("ripple") + + self.ripple_pool.emit( + coord, + on_emit=activate_ripple, + reset=ParticleReset(clear_paths=True, clear_scenes=False, clear_events=False), + ) + + def _start_transporting(self, hook: HookState) -> None: + """Move a reeled catch horizontally toward its final column.""" + assert hook.target is not None + hook.path_count += 1 + transport_path = hook.hook_character.motion.new_path( + speed=self.config.reel_speed, + ease=easing.in_out_sine, + path_id=f"transport_{hook.path_count}", + ) + transport_path.new_waypoint( + Coord(hook.target.input_coord.column, hook.hook_character.motion.current_coord.row), + ) + hook.hook_character.motion.activate_path(transport_path) + self.active_characters.add(hook.hook_character) + hook.phase = HookPhase.TRANSPORTING + + def _start_lowering(self, hook: HookState) -> None: + """Lower an attached catch to the release row above its destination.""" + assert hook.target is not None + release_hook_row = min(self.terminal.canvas.top, hook.target.input_coord.row + 1) + hook.path_count += 1 + lowering_path = hook.hook_character.motion.new_path( + speed=self.config.reel_speed, + ease=easing.in_sine, + path_id=f"lower_{hook.path_count}", + ) + lowering_path.new_waypoint(Coord(hook.target.input_coord.column, release_hook_row)) + hook.hook_character.motion.activate_path(lowering_path) + self.active_characters.add(hook.hook_character) + hook.phase = HookPhase.LOWERING + + def _set_final_appearance(self, character: EffectCharacter) -> None: + """Restore one input character's exact symbol and intended final colors.""" + character.animation.deactivate_scene() + character.animation.set_appearance(character.input_symbol, self.character_final_color_map[character]) + + def _start_releasing(self, hook: HookState) -> None: + """Place and detach the caught character at its immutable input coordinate.""" + assert hook.target is not None + hook.target.motion.deactivate_path() + hook.target.motion.set_coordinate(hook.target.input_coord) + hook.target.layer = 0 + self._set_final_appearance(hook.target) + self.terminal.set_character_visibility(hook.target, is_visible=True) + hook.caught_character = None + hook.release_ticks_remaining = self.RELEASE_FRAMES + hook.phase = HookPhase.RELEASING + + def _start_returning(self, hook: HookState, *, retry_target: bool = False) -> None: + """Reel an empty hook to the top and slide it back to its home column.""" + hook.path_count += 1 + return_path = hook.hook_character.motion.new_path( + speed=self.config.reel_speed, + ease=easing.out_sine, + path_id=f"return_{hook.path_count}", + ) + current_column = hook.hook_character.motion.current_coord.column + top_current_column = Coord(current_column, self.terminal.canvas.top) + return_path.new_waypoint(top_current_column) + home_coord = Coord(hook.home_column, self.terminal.canvas.top) + if home_coord != top_current_column: + return_path.new_waypoint(home_coord) + hook.hook_character.motion.activate_path(return_path) + self.active_characters.add(hook.hook_character) + hook.returning_from_wrong = retry_target + hook.phase = HookPhase.RETURNING + + def _finish_returning(self, hook: HookState) -> None: + """Reset a returned hook for its next target or hide it permanently.""" + if not hook.returning_from_wrong: + hook.target = None + hook.bite_origin = None + hook.caught_character = None + if hook.returning_from_wrong or hook.assignments: + hook.delay = self.config.cast_delay + hook.phase = HookPhase.WAITING + else: + hook.phase = HookPhase.FINISHED + self.terminal.set_character_visibility(hook.hook_character, is_visible=False) + for line_character in hook.line_characters: + self.terminal.set_character_visibility(line_character, is_visible=False) + hook.returning_from_wrong = False + + def _sync_caught_character(self, hook: HookState) -> None: + """Keep an attached input character immediately below its hook when possible.""" + if hook.caught_character is None: + return + hook_coord = hook.hook_character.motion.current_coord + caught_row = hook_coord.row - 1 if hook_coord.row > self.terminal.canvas.bottom else hook_coord.row + hook.caught_character.motion.set_coordinate(Coord(hook_coord.column, caught_row)) + + def _cleanup(self) -> None: + """Hide every auxiliary and force all input characters into their exact final state.""" + for auxiliary in self.terminal.get_characters(input_chars=False, added_chars=True): + auxiliary.motion.deactivate_path() + auxiliary.animation.deactivate_scene() + self.terminal.set_character_visibility(auxiliary, is_visible=False) + for junk in self.junk_pool.particles: + self.junk_pool.reclaim(junk) + for character in self.terminal.get_characters(): + character.motion.deactivate_path() + character.motion.set_coordinate(character.input_coord) + character.layer = 0 + self._set_final_appearance(character) + self.terminal.set_character_visibility(character, is_visible=True) + self.active_characters.clear() + self._cleanup_complete = True + self._final_hold_frames_remaining = self.FINAL_HOLD_FRAMES - 1 + + def __next__(self) -> str: + """Advance the Fishing choreography and return the next rendered frame.""" + if self._cleanup_complete: + if self._final_hold_frames_remaining: + self._final_hold_frames_remaining -= 1 + return self.frame + raise StopIteration + + for hook in self.hooks: + if hook.phase is HookPhase.CASTING and hook.hook_character.motion.active_path is None: + if hook.casting_junk: + self._start_wrong_catch(hook) + else: + hook.phase = HookPhase.BITING + hook.bite_ticks_remaining = self.BITE_FRAMES + assert hook.target is not None + hook.bite_origin = hook.target.motion.current_coord + self._emit_ripple(hook.bite_origin) + elif hook.phase is HookPhase.BITING: + if hook.bite_ticks_remaining: + self._wiggle_target(hook) + else: + self._start_reeling(hook) + elif hook.phase is HookPhase.REELING and hook.hook_character.motion.active_path is None: + self._start_transporting(hook) + elif hook.phase is HookPhase.TRANSPORTING and hook.hook_character.motion.active_path is None: + self._start_lowering(hook) + elif hook.phase is HookPhase.LOWERING and hook.hook_character.motion.active_path is None: + self._start_releasing(hook) + elif hook.phase is HookPhase.RELEASING: + if hook.release_ticks_remaining: + hook.release_ticks_remaining -= 1 + else: + self._start_returning(hook) + elif hook.phase is HookPhase.WRONG_CATCH: + self._advance_wrong_catch(hook) + elif hook.phase is HookPhase.RETURNING and hook.hook_character.motion.active_path is None: + self._finish_returning(hook) + elif hook.phase is HookPhase.WAITING and hook.assignments: + if hook.delay: + hook.delay -= 1 + else: + self._start_cast(hook) + + self.update() + for hook in self.hooks: + self._sync_caught_character(hook) + self._sync_line(hook) + if not self.hooks or all(hook.phase is HookPhase.FINISHED for hook in self.hooks): + self._cleanup() + return self.frame + + +class Fishing(BaseEffect[FishingConfig]): + """Catch scattered characters and reel them into their final text positions.""" + + @property + def _config_cls(self) -> type[FishingConfig]: + return FishingConfig + + @property + def _iterator_cls(self) -> type[FishingIterator]: + return FishingIterator diff --git a/terminaltexteffects/engine/terminal.py b/terminaltexteffects/engine/terminal.py index 2bb57882..fa75936a 100644 --- a/terminaltexteffects/engine/terminal.py +++ b/terminaltexteffects/engine/terminal.py @@ -564,7 +564,7 @@ def __init__(self, input_data: str, config: TerminalConfig | None = None) -> Non self.config = TerminalConfig._build_config() else: self.config = config - if not input_data: + if not input_data.strip(): input_data = "No Input." self._next_character_id = 0 self._input_colors_frequency: dict[Color, int] = {} diff --git a/tests/conftest.py b/tests/conftest.py index 0e932f92..9dc994d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ effect_errorcorrect, effect_expand, effect_fireworks, + effect_fishing, effect_highlight, effect_laseretch, effect_matrix, @@ -162,6 +163,7 @@ effect_errorcorrect.ErrorCorrect, effect_expand.Expand, effect_fireworks.Fireworks, + effect_fishing.Fishing, effect_highlight.Highlight, effect_laseretch.LaserEtch, effect_matrix.Matrix, diff --git a/tests/effects_tests/test_fishing.py b/tests/effects_tests/test_fishing.py new file mode 100644 index 00000000..5eb7bd04 --- /dev/null +++ b/tests/effects_tests/test_fishing.py @@ -0,0 +1,535 @@ +"""Tests for the Fishing effect.""" + +from __future__ import annotations + +import argparse +import importlib.util +import random +from collections import Counter +from importlib import import_module +from itertools import islice + +import pytest + +from terminaltexteffects.engine.base_config import BaseConfig +from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator +from terminaltexteffects.engine.terminal import TerminalConfig +from terminaltexteffects.utils.graphics import Color, ColorPair, Gradient + + +def _drain_with_frame_guard(iterator: BaseEffectIterator, limit: int = 5_000) -> int: + """Consume a real effect iterator and fail instead of allowing a termination regression to hang.""" + frame_count = sum(1 for _ in islice(iterator, limit)) + if frame_count == limit: + try: + next(iterator) + except StopIteration: + return frame_count + pytest.fail(f"Effect did not terminate within {limit} frames") + return frame_count + + +def test_fishing_module_is_available() -> None: + """The Fishing effect module should be importable.""" + assert importlib.util.find_spec("terminaltexteffects.effects.effect_fishing") is not None + + +def test_fishing_module_exposes_effect_types() -> None: + """The Fishing module should expose the conventional effect, config, and iterator types.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + + assert issubclass(module.Fishing, BaseEffect) + assert issubclass(module.FishingConfig, BaseConfig) + assert issubclass(module.FishingIterator, BaseEffectIterator) + + +def test_fishing_resources_register_the_fishing_command() -> None: + """Dynamic effect discovery should receive the Fishing resource tuple.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + + assert module.get_effect_resources() == ("fishing", module.Fishing, module.FishingConfig) + + +def test_fishing_config_builds_expected_defaults() -> None: + """The default Fishing configuration should expose the intended compact control surface.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + + config = module.FishingConfig._build_config() + + assert config.hook_count == 3 + assert config.line_color == Color("#D6F6FF") + assert config.water_colors == (Color("#0B7285"), Color("#1098AD"), Color("#66D9E8")) + assert config.cast_speed == 0.75 + assert config.reel_speed == 1.25 + assert config.cast_delay == 4 + assert config.wrong_catch_chance == 0.05 + assert config.final_gradient_stops == (Color("#1E90FF"), Color("#00D1B2"), Color("#FFE66D")) + assert config.final_gradient_steps == 12 + assert config.final_gradient_direction is Gradient.Direction.HORIZONTAL + + +@pytest.mark.parametrize( + "arguments", + [ + ["--hook-count", "0"], + ["--cast-speed", "0"], + ["--reel-speed", "-1"], + ["--cast-delay", "-1"], + ["--wrong-catch-chance", "1.1"], + ], +) +def test_fishing_config_rejects_invalid_numeric_options(arguments: list[str]) -> None: + """Fishing numeric controls should use the repository's bounded validators.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + parser = argparse.ArgumentParser() + module.FishingConfig._populate_parser(parser) + + with pytest.raises(SystemExit, match="2"): + parser.parse_args(arguments) + + +def test_fishing_is_exported_from_effects_package() -> None: + """Library users should be able to import Fishing from the effects package.""" + effects_package = import_module("terminaltexteffects.effects") + fishing_module = import_module("terminaltexteffects.effects.effect_fishing") + + assert effects_package.Fishing is fishing_module.Fishing + + +@pytest.mark.parametrize( + ("input_data", "canvas_width", "configured_hooks", "expected_hooks"), + [("AB", 2, 10, 2), ("A\nB", 1, 4, 1), ("A", 8, 4, 1)], +) +def test_fishing_clamps_hook_count_to_characters_and_canvas_width( + input_data: str, + canvas_width: int, + configured_hooks: int, + expected_hooks: int, +) -> None: + """Fishing should never create more useful hooks than targets or columns.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = canvas_width + terminal_config.canvas_height = 4 + effect = module.Fishing(input_data, terminal_config=terminal_config) + effect.effect_config.hook_count = configured_hooks + + iterator = iter(effect) + + assert len(iterator.hooks) == expected_hooks + + +def test_fishing_scattered_start_coordinates_are_unique_and_in_canvas() -> None: + """Every catchable character should begin at a valid, noncompeting water coordinate.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 10 + terminal_config.canvas_height = 5 + random.seed(17) + + iterator = iter(module.Fishing("FISHING", terminal_config=terminal_config)) + start_coordinates = list(iterator.character_start_coord_map.values()) + + assert set(iterator.character_start_coord_map) == set(iterator.catchable_characters) + assert all(iterator.terminal.canvas.coord_is_in_canvas(coord) for coord in start_coordinates) + assert len(start_coordinates) == len(set(start_coordinates)) + assert all( + character.motion.current_coord == coord for character, coord in iterator.character_start_coord_map.items() + ) + assert all(character.input_coord != coord for character, coord in iterator.character_start_coord_map.items()) + + +def test_fishing_assignments_cover_targets_once_in_balanced_column_regions() -> None: + """Hook queues should be balanced, exhaustive, and spatially coherent by final column.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 12 + terminal_config.canvas_height = 6 + random.seed(23) + effect = module.Fishing("ABCDEFGHI", terminal_config=terminal_config) + effect.effect_config.hook_count = 3 + + iterator = iter(effect) + assigned = [character for hook in iterator.hooks for character in hook.assignments] + assignment_sizes = [len(hook.assignments) for hook in iterator.hooks] + column_regions = [sorted(character.input_coord.column for character in hook.assignments) for hook in iterator.hooks] + + assert Counter(assigned) == Counter(iterator.catchable_characters) + assert max(assignment_sizes) - min(assignment_sizes) <= 1 + assert all(left[-1] <= right[0] for left, right in zip(column_regions, column_regions[1:])) + + +def test_fishing_characters_begin_visible_and_swimming_in_water_colors() -> None: + """Scattered input symbols should visibly bob before a hook selects them.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 10 + terminal_config.canvas_height = 5 + random.seed(29) + + iterator = iter(module.Fishing("FISH", terminal_config=terminal_config)) + + for character in iterator.catchable_characters: + assert character.is_visible + assert character.motion.active_path is not None + assert character.animation.current_character_visual.symbol == character.input_symbol + assert character.animation.current_character_visual.colors in tuple( + ColorPair(fg=color) for color in iterator.config.water_colors + ) + + +def test_fishing_preallocates_one_reusable_line_and_hook_per_hook_state() -> None: + """Each hook should own fixed auxiliary characters instead of allocating per frame.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 8 + terminal_config.canvas_height = 5 + effect = module.Fishing("ABCD", terminal_config=terminal_config) + effect.effect_config.hook_count = 2 + + iterator = iter(effect) + + for hook in iterator.hooks: + assert hook.hook_character.input_symbol == "J" + assert hook.hook_character.is_visible + assert hook.hook_character.motion.current_coord == module.Coord(hook.home_column, iterator.terminal.canvas.top) + assert len(hook.line_characters) == iterator.terminal.canvas.height - 1 + assert all(character.input_symbol == "|" for character in hook.line_characters) + assert not any(character.is_visible for character in hook.line_characters) + + +def test_fishing_line_resizes_and_repositions_without_stale_cells() -> None: + """Line auxiliaries should exactly fill the cells above the moving hook endpoint.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 6 + terminal_config.canvas_height = 5 + iterator = iter(module.Fishing("A", terminal_config=terminal_config)) + hook = iterator.hooks[0] + + hook.hook_character.motion.set_coordinate(module.Coord(2, 2)) + iterator._sync_line(hook) + visible_coordinates = {character.motion.current_coord for character in hook.line_characters if character.is_visible} + assert visible_coordinates == {module.Coord(2, row) for row in range(3, 6)} + + hook.hook_character.motion.set_coordinate(module.Coord(5, 4)) + iterator._sync_line(hook) + visible_coordinates = {character.motion.current_coord for character in hook.line_characters if character.is_visible} + assert visible_coordinates == {module.Coord(5, 5)} + assert not any( + character.is_visible and character.motion.current_coord.column == 2 for character in hook.line_characters + ) + + +def test_fishing_waiting_hook_starts_a_cast_toward_its_first_target() -> None: + """A ready hook should stop its target's swim and cast from the top toward it.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 10 + terminal_config.canvas_height = 6 + effect = module.Fishing("AB", terminal_config=terminal_config) + effect.effect_config.hook_count = 1 + effect.effect_config.cast_delay = 0 + effect.effect_config.cast_speed = 0.1 + effect.effect_config.wrong_catch_chance = 0 + random.seed(31) + iterator = iter(effect) + hook = iterator.hooks[0] + expected_target = hook.assignments[0] + + frame = next(iterator) + + assert isinstance(frame, str) + assert hook.phase is module.HookPhase.CASTING + assert hook.target is expected_target + assert expected_target.motion.active_path is None + assert hook.hook_character.motion.active_path is not None + assert ( + hook.hook_character.motion.active_path.waypoints[-1].coord.column == expected_target.motion.current_coord.column + ) + + +def test_fishing_cast_completion_enters_a_short_bite_phase() -> None: + """A hook reaching its target should pause for an observable bite cue.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 8 + terminal_config.canvas_height = 5 + effect = module.Fishing("A", terminal_config=terminal_config) + effect.effect_config.cast_delay = 0 + effect.effect_config.cast_speed = 10 + effect.effect_config.wrong_catch_chance = 0 + random.seed(37) + iterator = iter(effect) + hook = iterator.hooks[0] + + for _ in range(10): + next(iterator) + if hook.phase is module.HookPhase.BITING: + break + + assert hook.phase is module.HookPhase.BITING + assert hook.target is not None + assert hook.bite_ticks_remaining == iterator.BITE_FRAMES + + +def test_fishing_bite_wiggles_then_attaches_the_real_character_for_reeling() -> None: + """The bite cue should visibly tug the target before the hook carries that same input character.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 8 + terminal_config.canvas_height = 6 + effect = module.Fishing("A", terminal_config=terminal_config) + effect.effect_config.cast_delay = 0 + effect.effect_config.cast_speed = 10 + effect.effect_config.reel_speed = 1 + effect.effect_config.wrong_catch_chance = 0 + random.seed(41) + iterator = iter(effect) + hook = iterator.hooks[0] + + for _ in range(10): + next(iterator) + if hook.phase is module.HookPhase.BITING: + break + assert hook.target is not None + target = hook.target + bite_coordinates = [target.motion.current_coord] + + for _ in range(iterator.BITE_FRAMES + 2): + next(iterator) + bite_coordinates.append(target.motion.current_coord) + if hook.phase is module.HookPhase.REELING: + break + + assert len(set(bite_coordinates)) > 1 + assert hook.phase is module.HookPhase.REELING + assert hook.caught_character is target + assert ( + hook.hook_character.motion.active_path is not None + or hook.hook_character.motion.current_coord.row >= iterator.terminal.canvas.top - 1 + ) + + +def test_fishing_single_character_routes_releases_cleans_up_and_terminates() -> None: + """A complete catch should traverse every route phase and leave only exact final input.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 8 + terminal_config.canvas_height = 6 + terminal_config.frame_rate = 0 + effect = module.Fishing("A", terminal_config=terminal_config) + effect.effect_config.cast_delay = 0 + effect.effect_config.cast_speed = 10 + effect.effect_config.reel_speed = 10 + effect.effect_config.wrong_catch_chance = 0 + random.seed(43) + iterator = iter(effect) + hook = iterator.hooks[0] + observed_phases = {hook.phase.name} + + for _ in range(200): + try: + next(iterator) + except StopIteration: + break + observed_phases.add(hook.phase.name) + else: + pytest.fail("Fishing did not terminate within the single-character frame guard") + + assert { + "WAITING", + "CASTING", + "BITING", + "REELING", + "TRANSPORTING", + "LOWERING", + "RELEASING", + "RETURNING", + "FINISHED", + } <= observed_phases + character = iterator.terminal.get_characters()[0] + assert character.motion.current_coord == character.input_coord + assert character.animation.current_character_visual.symbol == character.input_symbol + assert character.is_visible + assert not any( + auxiliary.is_visible for auxiliary in iterator.terminal.get_characters(input_chars=False, added_chars=True) + ) + + +def test_fishing_bite_ripple_uses_and_returns_a_preallocated_particle() -> None: + """Bite ripples should reuse a bounded helper pool and reclaim themselves after their scene.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 8 + terminal_config.canvas_height = 6 + effect = module.Fishing("A", terminal_config=terminal_config) + effect.effect_config.cast_delay = 0 + effect.effect_config.cast_speed = 10 + effect.effect_config.wrong_catch_chance = 0 + random.seed(47) + iterator = iter(effect) + hook = iterator.hooks[0] + + assert len(iterator.ripple_pool) == 1 + assert len(iterator.ripple_pool.available) == 1 + + for _ in range(10): + next(iterator) + if hook.phase is module.HookPhase.BITING: + break + + ripple = iterator.ripple_pool.particles[0] + assert ripple.is_visible + assert ripple in iterator.active_characters + + for _ in range(10): + next(iterator) + if ripple in iterator.ripple_pool.available: + break + + assert not ripple.is_visible + assert ripple not in iterator.active_characters + assert ripple in iterator.ripple_pool.available + + +def test_fishing_wrong_catches_are_one_per_hook_harmless_and_cleaned_up() -> None: + """Forced junk catches should remain bounded auxiliaries and never replace input targets.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 10 + terminal_config.canvas_height = 6 + terminal_config.frame_rate = 0 + effect = module.Fishing("ABCD", terminal_config=terminal_config) + effect.effect_config.hook_count = 2 + effect.effect_config.cast_delay = 0 + effect.effect_config.cast_speed = 10 + effect.effect_config.reel_speed = 10 + effect.effect_config.wrong_catch_chance = 1 + random.seed(53) + iterator = iter(effect) + observed_wrong_catch = False + + for _ in range(500): + try: + next(iterator) + except StopIteration: + break + observed_wrong_catch |= any(hook.phase.name == "WRONG_CATCH" for hook in iterator.hooks) + else: + pytest.fail("Forced wrong catches did not terminate within the frame guard") + + assert observed_wrong_catch + assert all(hook.wrong_catches_used == 1 for hook in iterator.hooks) + assert len(iterator.junk_pool) == len(iterator.hooks) + assert len(iterator.junk_pool.available) == len(iterator.hooks) + assert not any(junk.is_visible for junk in iterator.junk_pool.particles) + assert [ + character.animation.current_character_visual.symbol for character in iterator.terminal.get_characters() + ] == [character.input_symbol for character in iterator.terminal.get_characters()] + + +def test_fishing_multiple_hooks_progress_concurrently() -> None: + """Suitable input should have more than one independent hook doing useful work in the same frame.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 12 + terminal_config.canvas_height = 7 + terminal_config.frame_rate = 0 + effect = module.Fishing("ABCDEF", terminal_config=terminal_config) + effect.effect_config.hook_count = 3 + effect.effect_config.cast_delay = 0 + effect.effect_config.cast_speed = 10 + effect.effect_config.reel_speed = 10 + effect.effect_config.wrong_catch_chance = 0 + random.seed(59) + iterator = iter(effect) + peak_concurrent_hooks = 0 + + for _ in range(500): + try: + next(iterator) + except StopIteration: + break + concurrent_hooks = sum(hook.phase.name not in {"WAITING", "FINISHED"} for hook in iterator.hooks) + peak_concurrent_hooks = max(peak_concurrent_hooks, concurrent_hooks) + else: + pytest.fail("Concurrent Fishing hooks did not terminate within the frame guard") + + assert peak_concurrent_hooks >= 2 + assert all(hook.phase.name == "FINISHED" for hook in iterator.hooks) + + +@pytest.mark.parametrize( + ("input_data", "canvas_width", "canvas_height"), + [ + ("", -1, -1), + (" \n ", -1, -1), + ("A", 1, 1), + ("ABCD", 1, 1), + ("ABCD", 4, 1), + ("A B", 3, 1), + ("A\nB\nC", 1, 3), + ("AB\nCD", 2, 2), + ], +) +def test_fishing_edge_inputs_finish_exactly_without_auxiliaries( + input_data: str, + canvas_width: int, + canvas_height: int, +) -> None: + """Empty, whitespace, tiny, single-axis, and multiline canvases should degrade and finish safely.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = canvas_width + terminal_config.canvas_height = canvas_height + terminal_config.frame_rate = 0 + effect = module.Fishing(input_data, terminal_config=terminal_config) + effect.effect_config.cast_delay = 0 + effect.effect_config.cast_speed = 10 + effect.effect_config.reel_speed = 10 + effect.effect_config.wrong_catch_chance = 0 + random.seed(61) + iterator = iter(effect) + + _drain_with_frame_guard(iterator) + + assert all( + character.motion.current_coord == character.input_coord for character in iterator.terminal.get_characters() + ) + assert all( + character.animation.current_character_visual.symbol == character.input_symbol + for character in iterator.terminal.get_characters() + ) + assert all(character.is_visible for character in iterator.terminal.get_characters()) + assert not any( + auxiliary.is_visible for auxiliary in iterator.terminal.get_characters(input_chars=False, added_chars=True) + ) + + +@pytest.mark.parametrize("existing_color_handling", ["always", "dynamic", "ignore"]) +def test_fishing_final_colors_follow_terminal_color_handling(existing_color_handling: str) -> None: + """Fishing should restore input colors or its final gradient according to terminal policy.""" + module = import_module("terminaltexteffects.effects.effect_fishing") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = 8 + terminal_config.canvas_height = 5 + terminal_config.frame_rate = 0 + terminal_config.existing_color_handling = existing_color_handling + input_data = "\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m" + effect = module.Fishing(input_data, terminal_config=terminal_config) + effect.effect_config.cast_delay = 0 + effect.effect_config.cast_speed = 10 + effect.effect_config.reel_speed = 10 + effect.effect_config.wrong_catch_chance = 0 + random.seed(67) + iterator = iter(effect) + + _drain_with_frame_guard(iterator) + + character = iterator.terminal.get_characters()[0] + if existing_color_handling == "ignore": + assert character.animation.current_character_visual.colors == iterator.character_final_color_map[character] + assert character.animation.current_character_visual.colors != ColorPair(fg=Color(196), bg=Color(106)) + else: + assert character.animation.current_character_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) diff --git a/tests/engine_tests/test_terminal.py b/tests/engine_tests/test_terminal.py index 27336aa0..a1c70707 100644 --- a/tests/engine_tests/test_terminal.py +++ b/tests/engine_tests/test_terminal.py @@ -137,6 +137,24 @@ def test_terminal_init_no_input() -> None: assert len(terminal.get_characters()) == 8 +def test_terminal_init_whitespace_only_matches_empty_input() -> None: + """Whitespace-only library input should use the established empty-input placeholder.""" + try: + whitespace_terminal = Terminal(input_data=" \t\n ") + except ValueError as error: + pytest.fail(f"Whitespace-only input should not fail canvas anchoring: {error}") + + empty_terminal = Terminal(input_data="") + whitespace_characters = [ + (character.input_symbol, character.input_coord) for character in whitespace_terminal.get_characters() + ] + empty_characters = [ + (character.input_symbol, character.input_coord) for character in empty_terminal.get_characters() + ] + + assert whitespace_characters == empty_characters + + def test_terminal_init_ignore_terminal_dimensions() -> None: config = TerminalConfig._build_config() config.ignore_terminal_dimensions = True diff --git a/tests/test_cli.py b/tests/test_cli.py index db092da1..b4e426db 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -88,10 +88,37 @@ def test_build_parser_registers_effects() -> None: assert "matrix" in effect_resource_map assert "highlight" in effect_resource_map + assert "fishing" in effect_resource_map help_output = parser.format_help() assert "matrix" in help_output assert "highlight" in help_output + assert "fishing" in help_output + + +def test_fishing_cli_help_exposes_core_options(capsys: pytest.CaptureFixture[str]) -> None: + """The dynamically discovered Fishing subcommand should publish its compact configuration surface.""" + parser, _ = __main__.build_parser() + + with pytest.raises(SystemExit, match="0"): + parser.parse_args(["fishing", "--help"]) + + help_output = capsys.readouterr().out + assert "--hook-count" in help_output + assert "--line-color" in help_output + assert "--wrong-catch-chance" in help_output + + +def test_fishing_cli_executes_representative_input() -> None: + """The actual module CLI should render Fishing successfully from piped input.""" + result = _run_bash( + "printf FISH | " + f"{sys.executable} -m terminaltexteffects --frame-rate 0 --canvas-width 8 --canvas-height 5 --seed 7 " + "fishing --hook-count 2 --cast-delay 0 --cast-speed 10 --reel-speed 10 --wrong-catch-chance 0", + ) + + assert all(symbol in result.stdout for symbol in "FISH") + assert "J" in result.stdout def test_main_print_completion_bash_outputs_script(