@@ -93,7 +93,7 @@ def _capture_screenshot(self, label: str = "Screenshot") -> None:
9393 'style="max-width:100%;border:1px solid #555;border-radius:4px;"/>'
9494 )
9595 logger .info (f"<p><b>{ label } </b></p>{ img_tag } " , html = True )
96- except Exception as exc : # noqa: BLE001
96+ except Exception as exc :
9797 logger .warn (f"Failed to capture screenshot: { exc } " )
9898
9999 def _require_game (self ) -> dict :
@@ -163,7 +163,7 @@ async def close_pokemon_terminal(self) -> None:
163163 if self ._run_test_cm is not None :
164164 try :
165165 await self ._run_test_cm .__aexit__ (None , None , None )
166- except Exception as exc : # noqa: BLE001
166+ except Exception as exc :
167167 logger .warn (f"Error while closing terminal: { exc } " )
168168 self ._app = None
169169 self ._pilot = None
@@ -173,7 +173,7 @@ async def close_pokemon_terminal(self) -> None:
173173 # ── Game state setup ──────────────────────────────────────────────────────
174174
175175 @keyword ("Bootstrap Game" )
176- def bootstrap_game (self , location : str = "Pallet Town" ) -> None :
176+ async def bootstrap_game (self , location : str = "Pallet Town" ) -> None :
177177 """
178178 Reset to a clean in-game state without going through the UI new-game flow.
179179
@@ -256,6 +256,9 @@ def bootstrap_game(self, location: str = "Pallet Town") -> None:
256256 self ._app ._refresh_subtitle ()
257257 self ._app .query_one ("#command-input" , Input ).focus ()
258258
259+ # ── 5. Wait for all Textual messages to settle ───────────────────────
260+ await self ._pilot .pause ()
261+
259262 logger .info (f"Game bootstrapped at '{ location } ' via temp save" )
260263
261264 # ── Input / interaction ───────────────────────────────────────────────────
@@ -434,9 +437,7 @@ def output_should_contain(self, expected: str, case_insensitive: bool = False) -
434437 haystack = text .lower () if case_insensitive else text
435438 needle = expected .lower () if case_insensitive else expected
436439 if needle not in haystack :
437- raise AssertionError (
438- f"Output does not contain { expected !r} .\n \n Full output:\n { text } "
439- )
440+ raise AssertionError (f"Output does not contain { expected !r} .\n \n Full output:\n { text } " )
440441
441442 @keyword ("Output Should Not Contain" )
442443 def output_should_not_contain (self , unexpected : str , case_insensitive : bool = False ) -> None :
@@ -648,14 +649,10 @@ def party_pokemon_should_be(self, index: int, expected_name: str) -> None:
648649 party = self ._require_game ().get ("pokemon" , [])
649650 index = int (index )
650651 if index >= len (party ):
651- raise AssertionError (
652- f"Party index { index } out of range (party size: { len (party )} )"
653- )
652+ raise AssertionError (f"Party index { index } out of range (party size: { len (party )} )" )
654653 actual = party [index ]["name" ]
655654 if actual != expected_name :
656- raise AssertionError (
657- f"Party[{ index } ]: expected { expected_name !r} , got { actual !r} "
658- )
655+ raise AssertionError (f"Party[{ index } ]: expected { expected_name !r} , got { actual !r} " )
659656
660657 @keyword ("Should Be In Battle" )
661658 def should_be_in_battle (self ) -> None :
@@ -689,9 +686,7 @@ def pending_command_should_be(self, expected: str) -> None:
689686 """
690687 actual = self ._app .pending_command
691688 if actual != expected :
692- raise AssertionError (
693- f"Pending command: expected { expected !r} , got { actual !r} "
694- )
689+ raise AssertionError (f"Pending command: expected { expected !r} , got { actual !r} " )
695690
696691 @keyword ("Pending Command Should Be Empty" )
697692 def pending_command_should_be_empty (self ) -> None :
@@ -738,3 +733,253 @@ def widget_should_be_hidden(self, selector: str) -> None:
738733 widget = self ._app .query_one (css )
739734 if not widget .has_class ("hidden" ):
740735 raise AssertionError (f"Widget '{ selector } ' is visible but expected to be hidden" )
736+
737+ # ── Additional game-state manipulation keywords ───────────────────────────
738+
739+ @keyword ("Add Badge" )
740+ def add_badge (self , badge_name : str ) -> None :
741+ """
742+ Add a gym badge to the player's badge collection.
743+
744+ Accepts the full badge name (e.g. ``Boulder Badge``) and converts it
745+ to the internal badge ID before storing, which is the format used by
746+ ``game_data["badges"]``.
747+
748+ Arguments:
749+ - ``badge_name``: Full badge name, e.g. ``Boulder Badge``, ``Cascade Badge``.
750+
751+ Example:
752+ | Add Badge | Boulder Badge |
753+ | Add Badge | Cascade Badge |
754+ """
755+ from PokemonLibrary .gym_system import BADGES
756+
757+ badge_entry = BADGES .get (badge_name )
758+ if badge_entry is None :
759+ raise ValueError (f"Unknown badge: { badge_name !r} . Valid badges: { list (BADGES .keys ())} " )
760+ badge_id = badge_entry ["id" ]
761+
762+ gd = self ._require_game ()
763+ badges = gd .setdefault ("badges" , [])
764+ if badge_id not in badges :
765+ badges .append (badge_id )
766+
767+ @keyword ("Set Party Pokemon Level" )
768+ def set_party_pokemon_level (self , index : int , level : int ) -> None :
769+ """
770+ Set the level of a party Pokemon at *index* to *level*.
771+
772+ Arguments:
773+ - ``index``: 0-based party slot index.
774+ - ``level``: New level value (1-100).
775+
776+ Example:
777+ | Set Party Pokemon Level | 0 | 20 |
778+ """
779+ party = self ._require_game ().get ("pokemon" , [])
780+ index = int (index )
781+ if index >= len (party ):
782+ raise AssertionError (f"Party index { index } out of range (party size: { len (party )} )" )
783+ party [index ]["level" ] = int (level )
784+ self ._app .game_state ._deserialize_party ()
785+
786+ @keyword ("Set Pokemon HP" )
787+ def set_pokemon_hp (self , index : int , hp : int ) -> None :
788+ """
789+ Set the current HP of a party Pokemon at *index*.
790+
791+ Arguments:
792+ - ``index``: 0-based party slot index.
793+ - ``hp``: New HP value.
794+
795+ Example:
796+ | Set Pokemon HP | 0 | 1 |
797+ """
798+ party = self ._require_game ().get ("pokemon" , [])
799+ index = int (index )
800+ if index >= len (party ):
801+ raise AssertionError (f"Party index { index } out of range (party size: { len (party )} )" )
802+ party [index ]["hp" ] = int (hp )
803+ self ._app .game_state ._deserialize_party ()
804+
805+ # ── Additional game-state assertion keywords ──────────────────────────────
806+
807+ @keyword ("Badge Count Should Be" )
808+ def badge_count_should_be (self , expected : int ) -> None :
809+ """
810+ Fail if the number of earned badges does not equal *expected*.
811+
812+ Arguments:
813+ - ``expected``: Expected badge count (integer).
814+
815+ Example:
816+ | Badge Count Should Be | 0 |
817+ | Badge Count Should Be | 3 |
818+ """
819+ badges = self ._require_game ().get ("badges" , [])
820+ actual = len (badges )
821+ expected = int (expected )
822+ if actual != expected :
823+ raise AssertionError (f"Badge count: expected { expected } , got { actual } ({ badges } )" )
824+
825+ @keyword ("Party Size Should Be" )
826+ def party_size_should_be (self , expected : int ) -> None :
827+ """
828+ Fail if the number of Pokemon in the party does not equal *expected*.
829+
830+ Arguments:
831+ - ``expected``: Expected party size (integer, 0-6).
832+
833+ Example:
834+ | Party Size Should Be | 1 |
835+ | Party Size Should Be | 3 |
836+ """
837+ party = self ._require_game ().get ("pokemon" , [])
838+ actual = len (party )
839+ expected = int (expected )
840+ if actual != expected :
841+ raise AssertionError (f"Party size: expected { expected } , got { actual } " )
842+
843+ @keyword ("Party Pokemon Level Should Be" )
844+ def party_pokemon_level_should_be (self , index : int , expected_level : int ) -> None :
845+ """
846+ Fail if the level of the party Pokemon at *index* does not equal *expected_level*.
847+
848+ Arguments:
849+ - ``index``: 0-based party slot index.
850+ - ``expected_level``: Expected level value.
851+
852+ Example:
853+ | Party Pokemon Level Should Be | 0 | 11 |
854+ """
855+ party = self ._require_game ().get ("pokemon" , [])
856+ index = int (index )
857+ if index >= len (party ):
858+ raise AssertionError (f"Party index { index } out of range (party size: { len (party )} )" )
859+ actual = party [index ].get ("level" , 0 )
860+ expected_level = int (expected_level )
861+ if actual != expected_level :
862+ raise AssertionError (f"Party[{ index } ] level: expected { expected_level } , got { actual } " )
863+
864+ @keyword ("Story Flag Should Be Set" )
865+ def story_flag_should_be_set (self , flag_name : str ) -> None :
866+ """
867+ Fail if *flag_name* is not set (or is set to a falsy value) in story_flags.
868+
869+ Arguments:
870+ - ``flag_name``: Story flag key, e.g. ``rival_cerulean_beaten``.
871+
872+ Example:
873+ | Story Flag Should Be Set | rival_cerulean_beaten |
874+ """
875+ flags = self ._require_game ().get ("story_flags" , {})
876+ if not flags .get (flag_name ):
877+ raise AssertionError (f"Story flag '{ flag_name } ' is not set. Flags: { flags } " )
878+
879+ @keyword ("Story Flag Should Not Be Set" )
880+ def story_flag_should_not_be_set (self , flag_name : str ) -> None :
881+ """
882+ Fail if *flag_name* IS set (and truthy) in story_flags.
883+
884+ Arguments:
885+ - ``flag_name``: Story flag key, e.g. ``rival_cerulean_beaten``.
886+
887+ Example:
888+ | Story Flag Should Not Be Set | rival_cerulean_beaten |
889+ """
890+ flags = self ._require_game ().get ("story_flags" , {})
891+ if flags .get (flag_name ):
892+ raise AssertionError (f"Story flag '{ flag_name } ' is set but expected to be absent/falsy" )
893+
894+ @keyword ("Set Learn Move Prompt" )
895+ def set_learn_move_prompt (
896+ self , move_name : str , remaining : str = "" , post_action : str = "wild_end"
897+ ) -> None :
898+ """
899+ Inject a ``learn_move_choice`` pending-command so the lead Pokemon
900+ is offered a new move.
901+
902+ The lead Pokemon (party slot 0) must already have 4 moves; use
903+ ``Set Lead Pokemon`` before calling this keyword if needed.
904+
905+ Arguments:
906+ - ``move_name``: The name of the new move being offered (ALL CAPS).
907+ - ``remaining``: Space-separated list of additional queued move names (default empty).
908+ - ``post_action``: Value for ``learn_post_action`` (default ``wild_end``).
909+
910+ Example:
911+ | Set Lead Pokemon | CHARMANDER |
912+ | Set Learn Move Prompt | RAGE |
913+ | Type Command | 1 |
914+ | Pending Command Should Be Empty |
915+
916+ | Set Learn Move Prompt | RAGE | remaining=SLASH |
917+ """
918+ from PokemonLibrary .models import PartyPokemon
919+
920+ gd = self ._require_game ()
921+ party = gd .get ("pokemon" , [])
922+ if not party :
923+ raise RuntimeError ("Party is empty — call 'Bootstrap Game' first." )
924+
925+ # Convert PartyPokemon model to a plain dict so the game_flow handler can
926+ # use dict-style access (pokemon["moves"][slot]["name"] = …).
927+ lead_raw = party [0 ]
928+ if isinstance (lead_raw , PartyPokemon ):
929+ lead = lead_raw .to_dict ()
930+ else :
931+ lead = dict (lead_raw ) # copy to avoid mutation side-effects
932+
933+ # Ensure the lead has exactly 4 moves (fill with defaults if needed)
934+ moves = lead .setdefault ("moves" , [])
935+ default_moves = [
936+ {"name" : "TACKLE" , "pp" : 35 , "max_pp" : 35 },
937+ {"name" : "GROWL" , "pp" : 40 , "max_pp" : 40 },
938+ {"name" : "SCRATCH" , "pp" : 35 , "max_pp" : 35 },
939+ {"name" : "LEER" , "pp" : 30 , "max_pp" : 30 },
940+ ]
941+ while len (moves ) < 4 :
942+ moves .append (default_moves [len (moves )])
943+
944+ # Replace slot 0 with the plain dict so the handler can write to it directly
945+ party [0 ] = lead
946+
947+ remaining_list = [m .strip () for m in remaining .split () if m .strip ()]
948+
949+ self ._app .pending_command = "learn_move_choice"
950+ self ._app .pending_command_data = {
951+ "learn_pokemon" : lead ,
952+ "learn_move_name" : move_name .upper (),
953+ "learn_remaining" : remaining_list ,
954+ "learn_post_action" : post_action ,
955+ }
956+ logger .info (f"Learn-move prompt set: offering { move_name .upper ()} to { lead ['name' ]} " )
957+
958+ @keyword ("Register Pokemon As Seen" )
959+ def register_pokemon_as_seen (self , species_name : str ) -> None :
960+ """
961+ Register a Pokemon as seen in the Pokedex (test helper).
962+
963+ This allows viewing Pokedex entries without actually encountering the Pokemon.
964+
965+ Arguments:
966+ - ``species_name``: Pokemon species name (e.g. "Pikachu", "Zubat").
967+
968+ Example:
969+ | Register Pokemon As Seen | Zubat |
970+ | Type Command | pokedex entry zubat |
971+ | Output Should Contain | ZUBAT |
972+ """
973+ game_data = self ._require_game ()
974+ pokedex = game_data .get ("pokedex" , {"seen" : [], "caught" : []})
975+ if "pokedex" not in game_data :
976+ game_data ["pokedex" ] = pokedex
977+
978+ species_upper = species_name .upper ().replace (" " , "_" )
979+
980+ if species_upper not in pokedex .get ("seen" , []):
981+ seen_list = pokedex .get ("seen" , [])
982+ seen_list .append (species_upper )
983+ pokedex ["seen" ] = seen_list
984+
985+ logger .info (f"Registered { species_upper } as seen in Pokedex" )
0 commit comments