 920cffda2d
			
		
	
	920cffda2d
	
	
	
		
			
			* initial work on procedure patch * more flexibility load default procedure for version 5 patches add args for procedure add default extension for tokens and bsdiff allow specifying additional required extensions for generation * pushing current changes to go fix tloz bug * move tokens into a separate inheritable class * forgot the commit to remove token from ProcedurePatch * further cleaning from bad commit * start on docstrings * further work on docstrings and typing * improve docstrings * fix incorrect docstring * cleanup * clean defaults and docstring * define interface that has only the bare minimum required for `Patch.create_rom_file` * change to dictionary.get * remove unnecessary if statement * update to explicitly check for procedure, restore compatible version and manual override * Update Files.py * remove struct uses * Update Rom.py * convert KDL3 to APPP * change class variables to instance variables * Update worlds/Files.py Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> * Update worlds/Files.py Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> * move required_extensions to tuple * fix missing tuple ellipsis * fix classvar mixup * rename tokens to _tokens. use hasattr * type hint cleanup * Update Files.py * initial base for local items, need to finish * coo not clean * handle local items for real, appp cleanup * actually make bosses send their locations * fix cloudy park 4 rule, zero deathlink message * remove redundant door_shuffle bool when generic ER gets in, this whole function gets rewritten. So just clean it a little now. * properly fix deathlink messages, fix fill error * update docs * add prefill items * fix kine fill error * Update Rom.py * Update Files.py * mypy and softlock fix * Update Gifting.py * mypy phase 1 * fix rare async client bug * Update __init__.py * typing cleanup * fix stone softlock because of the way Kine's Stone works, you can't clear the stone blocks before clearing the burning blocks, so we have to bring Burning from outside * Update Rom.py * Add option groups * Rename to lowercase * finish rename * whoops broke the world * fix animal duplication bug * overhaul filler generation * add Miku flavor * Update gifting.py * fix issues related to max_hs increase * Update test_locations.py * fix boss shuffle not working if level shuffle is disabled * fix bleeding default levels * Update options.py * thought this would print seed * yay bad merges * forgot options too * yeah lets just break generation while at it * this is probably a problem * cap required heart stars * Revert "cap required heart stars" This reverts commit 759efd3e2b14ec2855082de041ac989cb9c5d500. * fix duplication removal placement, deprecated test option * forgot that we need to account for what we place * move location ids * rewrite trap handling * further stage renumber fixes * forgot one more * basic UT support * fix local heart star checks * fix pattern --------- Co-authored-by: beauxq <beauxq@yahoo.com> Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
		
			
				
	
	
		
			316 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			316 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| from typing import List, Tuple, Optional
 | |
| from . import KDL3TestBase
 | |
| from ..room import KDL3Room
 | |
| from ..names import animal_friend_spawns
 | |
| 
 | |
| 
 | |
| class TestCopyAbilityShuffle(KDL3TestBase):
 | |
|     options = {
 | |
|         "open_world": False,
 | |
|         "goal_speed": "normal",
 | |
|         "max_heart_stars": 30,
 | |
|         "heart_stars_required": 50,
 | |
|         "filler_percentage": 0,
 | |
|         "copy_ability_randomization": "enabled",
 | |
|     }
 | |
| 
 | |
|     def test_goal(self) -> None:
 | |
|         try:
 | |
|             self.assertBeatable(False)
 | |
|             heart_stars = self.get_items_by_name("Heart Star")
 | |
|             self.collect(heart_stars[0:14])
 | |
|             self.assertEqual(self.count("Heart Star"), 14, str(self.multiworld.seed))
 | |
|             self.assertBeatable(False)
 | |
|             self.collect(heart_stars[14:15])
 | |
|             self.assertEqual(self.count("Heart Star"), 15, str(self.multiworld.seed))
 | |
|             self.assertBeatable(False)
 | |
|             self.collect_by_name(["Burning", "Cutter", "Kine"])
 | |
|             self.assertBeatable(True)
 | |
|             self.remove([self.get_item_by_name("Love-Love Rod")])
 | |
|             self.collect(heart_stars)
 | |
|             self.assertEqual(self.count("Heart Star"), 30, str(self.multiworld.seed))
 | |
|             self.assertBeatable(True)
 | |
|         except AssertionError as ex:
 | |
|             # if assert beatable fails, this will catch and print the seed
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_kine(self) -> None:
 | |
|         try:
 | |
|             self.collect_by_name(["Cutter", "Burning", "Heart Star"])
 | |
|             self.assertBeatable(False)
 | |
|         except AssertionError as ex:
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_cutter(self) -> None:
 | |
|         try:
 | |
|             self.collect_by_name(["Kine", "Burning", "Heart Star"])
 | |
|             self.assertBeatable(False)
 | |
|         except AssertionError as ex:
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_burning(self) -> None:
 | |
|         try:
 | |
|             self.collect_by_name(["Cutter", "Kine", "Heart Star"])
 | |
|             self.assertBeatable(False)
 | |
|         except AssertionError as ex:
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_cutter_and_burning_reachable(self) -> None:
 | |
|         rooms = self.multiworld.worlds[1].rooms
 | |
|         copy_abilities = self.multiworld.worlds[1].copy_abilities
 | |
|         sand_canyon_5 = self.multiworld.get_region("Sand Canyon 5 - 9", 1)
 | |
|         assert isinstance(sand_canyon_5, KDL3Room)
 | |
|         valid_rooms = [room for room in rooms if (room.level < sand_canyon_5.level)
 | |
|                        or (room.level == sand_canyon_5.level and room.stage < sand_canyon_5.stage)]
 | |
|         for room in valid_rooms:
 | |
|             if any(copy_abilities[enemy] == "Cutter Ability" for enemy in room.enemies):
 | |
|                 break
 | |
|         else:
 | |
|             self.fail("Could not reach Cutter Ability before Sand Canyon 5!")
 | |
|         iceberg_4 = self.multiworld.get_region("Iceberg 4 - 7", 1)
 | |
|         assert isinstance(iceberg_4, KDL3Room)
 | |
|         valid_rooms = [room for room in rooms if (room.level < iceberg_4.level)
 | |
|                        or (room.level == iceberg_4.level and room.stage < iceberg_4.stage)]
 | |
|         for room in valid_rooms:
 | |
|             if any(copy_abilities[enemy] == "Burning Ability" for enemy in room.enemies):
 | |
|                 break
 | |
|         else:
 | |
|             self.fail("Could not reach Burning Ability before Iceberg 4!")
 | |
| 
 | |
|     def test_valid_abilities_for_ROB(self) -> None:
 | |
|         # there exists a subset of 4-7 abilities that will allow us access to ROB heart star on default settings
 | |
|         self.collect_by_name(["Heart Star", "Kine", "Coo"])  # we will guaranteed need Coo, Kine, and Heart Stars to reach
 | |
|         # first we need to identify our bukiset requirements
 | |
|         groups = [
 | |
|             ({"Parasol Ability", "Cutter Ability"}, {'Bukiset (Parasol)', 'Bukiset (Cutter)'}),
 | |
|             ({"Spark Ability", "Clean Ability"}, {'Bukiset (Spark)', 'Bukiset (Clean)'}),
 | |
|             ({"Ice Ability", "Needle Ability"}, {'Bukiset (Ice)', 'Bukiset (Needle)'}),
 | |
|             ({"Stone Ability", "Burning Ability"}, {'Bukiset (Stone)', 'Bukiset (Burning)'}),
 | |
|         ]
 | |
|         copy_abilities = self.multiworld.worlds[1].copy_abilities
 | |
|         required_abilities: List[List[str]] = []
 | |
|         for abilities, bukisets in groups:
 | |
|             potential_abilities: List[str] = list()
 | |
|             for bukiset in bukisets:
 | |
|                 if copy_abilities[bukiset] in abilities:
 | |
|                     potential_abilities.append(copy_abilities[bukiset])
 | |
|             required_abilities.append(potential_abilities)
 | |
|         collected_abilities = list()
 | |
|         for group in required_abilities:
 | |
|             self.assertFalse(len(group) == 0, str(self.multiworld.seed))
 | |
|             collected_abilities.append(group[0])
 | |
|         self.collect_by_name([ability.replace(" Ability", "") for ability in collected_abilities])
 | |
|         if "Parasol Ability" not in collected_abilities or "Stone Ability" not in collected_abilities:
 | |
|             # required for non-Bukiset related portions
 | |
|             self.collect_by_name(["Parasol", "Stone"])
 | |
| 
 | |
|         if "Cutter Ability" not in collected_abilities:
 | |
|             # we can't actually reach 3-6 without Cutter
 | |
|             self.assertFalse(self.can_reach_location("Sand Canyon 6 - Professor Hector & R.O.B"), str(self.multiworld.seed))
 | |
|             self.collect_by_name(["Cutter"])
 | |
| 
 | |
|         self.assertTrue(self.can_reach_location("Sand Canyon 6 - Professor Hector & R.O.B"),
 | |
|                         ''.join(str(self.multiworld.seed)).join(collected_abilities))
 | |
| 
 | |
| 
 | |
| class TestAnimalShuffle(KDL3TestBase):
 | |
|     options = {
 | |
|         "open_world": False,
 | |
|         "goal_speed": "normal",
 | |
|         "max_heart_stars": 30,
 | |
|         "heart_stars_required": 50,
 | |
|         "filler_percentage": 0,
 | |
|         "animal_randomization": "full",
 | |
|     }
 | |
| 
 | |
|     def test_goal(self) -> None:
 | |
|         try:
 | |
|             self.assertBeatable(False)
 | |
|             heart_stars = self.get_items_by_name("Heart Star")
 | |
|             self.collect(heart_stars[0:14])
 | |
|             self.assertEqual(self.count("Heart Star"), 14, str(self.multiworld.seed))
 | |
|             self.assertBeatable(False)
 | |
|             self.collect(heart_stars[14:15])
 | |
|             self.assertEqual(self.count("Heart Star"), 15, str(self.multiworld.seed))
 | |
|             self.assertBeatable(False)
 | |
|             self.collect_by_name(["Burning", "Cutter", "Kine"])
 | |
|             self.assertBeatable(True)
 | |
|             self.remove([self.get_item_by_name("Love-Love Rod")])
 | |
|             self.collect(heart_stars)
 | |
|             self.assertEqual(self.count("Heart Star"), 30, str(self.multiworld.seed))
 | |
|             self.assertBeatable(True)
 | |
|         except AssertionError as ex:
 | |
|             # if assert beatable fails, this will catch and print the seed
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_kine(self) -> None:
 | |
|         try:
 | |
|             self.collect_by_name(["Cutter", "Burning", "Heart Star"])
 | |
|             self.assertBeatable(False)
 | |
|         except AssertionError as ex:
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_cutter(self) -> None:
 | |
|         try:
 | |
|             self.collect_by_name(["Kine", "Burning", "Heart Star"])
 | |
|             self.assertBeatable(False)
 | |
|         except AssertionError as ex:
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_burning(self) -> None:
 | |
|         try:
 | |
|             self.collect_by_name(["Cutter", "Kine", "Heart Star"])
 | |
|             self.assertBeatable(False)
 | |
|         except AssertionError as ex:
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_locked_animals(self) -> None:
 | |
|         ripple_field_5 = self.multiworld.get_location("Ripple Field 5 - Animal 2", 1)
 | |
|         self.assertTrue(ripple_field_5.item is not None and ripple_field_5.item.name == "Pitch Spawn",
 | |
|                         f"Multiworld did not place Pitch, Seed: {self.multiworld.seed}")
 | |
|         iceberg_4 = self.multiworld.get_location("Iceberg 4 - Animal 1", 1)
 | |
|         self.assertTrue(iceberg_4.item is not None and iceberg_4.item.name == "ChuChu Spawn",
 | |
|                         f"Multiworld did not place ChuChu, Seed: {self.multiworld.seed}")
 | |
|         sand_canyon_6 = self.multiworld.get_location("Sand Canyon 6 - Animal 1", 1)
 | |
|         self.assertTrue(sand_canyon_6.item is not None and sand_canyon_6.item.name in
 | |
|                         {"Kine Spawn", "Coo Spawn"}, f"Multiworld did not place Coo/Kine, Seed: {self.multiworld.seed}")
 | |
| 
 | |
|     def test_problematic(self) -> None:
 | |
|         for spawns in animal_friend_spawns.problematic_sets:
 | |
|             placed = [self.multiworld.get_location(spawn, 1).item for spawn in spawns]
 | |
|             placed_names = set([item.name for item in placed])
 | |
|             self.assertEqual(len(placed), len(placed_names),
 | |
|                              f"Duplicate animal placed in problematic locations:"
 | |
|                              f" {[spawn.location for spawn in placed]}, "
 | |
|                              f"Seed: {self.multiworld.seed}")
 | |
| 
 | |
| 
 | |
| class TestAllShuffle(KDL3TestBase):
 | |
|     options = {
 | |
|         "open_world": False,
 | |
|         "goal_speed": "normal",
 | |
|         "max_heart_stars": 30,
 | |
|         "heart_stars_required": 50,
 | |
|         "filler_percentage": 0,
 | |
|         "animal_randomization": "full",
 | |
|         "copy_ability_randomization": "enabled",
 | |
|     }
 | |
| 
 | |
|     def test_goal(self) -> None:
 | |
|         try:
 | |
|             self.assertBeatable(False)
 | |
|             heart_stars = self.get_items_by_name("Heart Star")
 | |
|             self.collect(heart_stars[0:14])
 | |
|             self.assertEqual(self.count("Heart Star"), 14, str(self.multiworld.seed))
 | |
|             self.assertBeatable(False)
 | |
|             self.collect(heart_stars[14:15])
 | |
|             self.assertEqual(self.count("Heart Star"), 15, str(self.multiworld.seed))
 | |
|             self.assertBeatable(False)
 | |
|             self.collect_by_name(["Burning", "Cutter", "Kine"])
 | |
|             self.assertBeatable(True)
 | |
|             self.remove([self.get_item_by_name("Love-Love Rod")])
 | |
|             self.collect(heart_stars)
 | |
|             self.assertEqual(self.count("Heart Star"), 30, str(self.multiworld.seed))
 | |
|             self.assertBeatable(True)
 | |
|         except AssertionError as ex:
 | |
|             # if assert beatable fails, this will catch and print the seed
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_kine(self) -> None:
 | |
|         try:
 | |
|             self.collect_by_name(["Cutter", "Burning", "Heart Star"])
 | |
|             self.assertBeatable(False)
 | |
|         except AssertionError as ex:
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_cutter(self) -> None:
 | |
|         try:
 | |
|             self.collect_by_name(["Kine", "Burning", "Heart Star"])
 | |
|             self.assertBeatable(False)
 | |
|         except AssertionError as ex:
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_burning(self) -> None:
 | |
|         try:
 | |
|             self.collect_by_name(["Cutter", "Kine", "Heart Star"])
 | |
|             self.assertBeatable(False)
 | |
|         except AssertionError as ex:
 | |
|             raise AssertionError(f"Test failed, Seed:{self.multiworld.seed}") from ex
 | |
| 
 | |
|     def test_locked_animals(self) -> None:
 | |
|         ripple_field_5 = self.multiworld.get_location("Ripple Field 5 - Animal 2", 1)
 | |
|         self.assertTrue(ripple_field_5.item is not None and ripple_field_5.item.name == "Pitch Spawn",
 | |
|                         f"Multiworld did not place Pitch, Seed: {self.multiworld.seed}")
 | |
|         iceberg_4 = self.multiworld.get_location("Iceberg 4 - Animal 1", 1)
 | |
|         self.assertTrue(iceberg_4.item is not None and iceberg_4.item.name == "ChuChu Spawn",
 | |
|                         f"Multiworld did not place ChuChu, Seed: {self.multiworld.seed}")
 | |
|         sand_canyon_6 = self.multiworld.get_location("Sand Canyon 6 - Animal 1", 1)
 | |
|         self.assertTrue(sand_canyon_6.item is not None and sand_canyon_6.item.name in
 | |
|                         {"Kine Spawn", "Coo Spawn"}, f"Multiworld did not place Coo/Kine, Seed: {self.multiworld.seed}")
 | |
| 
 | |
|     def test_problematic(self) -> None:
 | |
|         for spawns in animal_friend_spawns.problematic_sets:
 | |
|             placed = [self.multiworld.get_location(spawn, 1).item for spawn in spawns]
 | |
|             placed_names = set([item.name for item in placed])
 | |
|             self.assertEqual(len(placed), len(placed_names),
 | |
|                              f"Duplicate animal placed in problematic locations:"
 | |
|                              f" {[spawn.location for spawn in placed]}, "
 | |
|                              f"Seed: {self.multiworld.seed}")
 | |
| 
 | |
|     def test_cutter_and_burning_reachable(self) -> None:
 | |
|         rooms = self.multiworld.worlds[1].rooms
 | |
|         copy_abilities = self.multiworld.worlds[1].copy_abilities
 | |
|         sand_canyon_5 = self.multiworld.get_region("Sand Canyon 5 - 9", 1)
 | |
|         assert isinstance(sand_canyon_5, KDL3Room)
 | |
|         valid_rooms = [room for room in rooms if (room.level < sand_canyon_5.level)
 | |
|                        or (room.level == sand_canyon_5.level and room.stage < sand_canyon_5.stage)]
 | |
|         for room in valid_rooms:
 | |
|             if any(copy_abilities[enemy] == "Cutter Ability" for enemy in room.enemies):
 | |
|                 break
 | |
|         else:
 | |
|             self.fail("Could not reach Cutter Ability before Sand Canyon 5!")
 | |
|         iceberg_4 = self.multiworld.get_region("Iceberg 4 - 7", 1)
 | |
|         assert isinstance(iceberg_4, KDL3Room)
 | |
|         valid_rooms = [room for room in rooms if (room.level < iceberg_4.level)
 | |
|                        or (room.level == iceberg_4.level and room.stage < iceberg_4.stage)]
 | |
|         for room in valid_rooms:
 | |
|             if any(copy_abilities[enemy] == "Burning Ability" for enemy in room.enemies):
 | |
|                 break
 | |
|         else:
 | |
|             self.fail("Could not reach Burning Ability before Iceberg 4!")
 | |
| 
 | |
|     def test_valid_abilities_for_ROB(self) -> None:
 | |
|         # there exists a subset of 4-7 abilities that will allow us access to ROB heart star on default settings
 | |
|         self.collect_by_name(["Heart Star", "Kine", "Coo"])  # we will guaranteed need Coo, Kine, and Heart Stars to reach
 | |
|         # first we need to identify our bukiset requirements
 | |
|         groups = [
 | |
|             ({"Parasol Ability", "Cutter Ability"}, {'Bukiset (Parasol)', 'Bukiset (Cutter)'}),
 | |
|             ({"Spark Ability", "Clean Ability"}, {'Bukiset (Spark)', 'Bukiset (Clean)'}),
 | |
|             ({"Ice Ability", "Needle Ability"}, {'Bukiset (Ice)', 'Bukiset (Needle)'}),
 | |
|             ({"Stone Ability", "Burning Ability"}, {'Bukiset (Stone)', 'Bukiset (Burning)'}),
 | |
|         ]
 | |
|         copy_abilities = self.multiworld.worlds[1].copy_abilities
 | |
|         required_abilities: List[List[str]] = []
 | |
|         for abilities, bukisets in groups:
 | |
|             potential_abilities: List[str] = list()
 | |
|             for bukiset in bukisets:
 | |
|                 if copy_abilities[bukiset] in abilities:
 | |
|                     potential_abilities.append(copy_abilities[bukiset])
 | |
|             required_abilities.append(potential_abilities)
 | |
|         collected_abilities = list()
 | |
|         for group in required_abilities:
 | |
|             self.assertFalse(len(group) == 0, str(self.multiworld.seed))
 | |
|             collected_abilities.append(group[0])
 | |
|         self.collect_by_name([ability.replace(" Ability", "") for ability in collected_abilities])
 | |
|         if "Parasol Ability" not in collected_abilities or "Stone Ability" not in collected_abilities:
 | |
|             # required for non-Bukiset related portions
 | |
|             self.collect_by_name(["Parasol", "Stone"])
 | |
| 
 | |
|         if "Cutter Ability" not in collected_abilities:
 | |
|             # we can't actually reach 3-6 without Cutter
 | |
|             self.assertFalse(self.can_reach_location("Sand Canyon 6 - Professor Hector & R.O.B"), str(self.multiworld.seed))
 | |
|             self.collect_by_name(["Cutter"])
 | |
| 
 | |
|         self.assertTrue(self.can_reach_location("Sand Canyon 6 - Professor Hector & R.O.B"),
 | |
|                         f"Seed: {self.multiworld.seed}, Collected: {collected_abilities}")
 |