From a948697f3a2387d13862a194690c39af8da2dbd8 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Mon, 9 Dec 2024 00:57:34 +0000 Subject: [PATCH 001/144] Raft: Place locked items in create_items and fix get_pre_fill_items (#4250) * Raft: Place locked items in create_items and fix get_pre_fill_items `pre_fill` runs after item plando, and item plando could place an item at a location where Raft was intending to place a locked item, which would crash generation. This patch moves the placement of these locked items earlier, into `create_items`. Setting items into `multiworld.raft_frequencyItemsPerPlayer` for each player has been replaced with passing `frequencyItems` to the new `place_frequencyItems` function. `setLocationItem` and `setLocationItemFromRegion` have been moved into the new `place_frequencyItems` function so that they can capture the `frequencyItems` argument variable. The `get_pre_fill_items` function could return a list of all previously placed items across the entire multiworld which was not correct. It should have returned the items in `multiworld.raft_frequencyItemsPerPlayer[self.player]`. Now that these items are placed in `create_items` instead of `pre_fill`, `get_pre_fill_items` is no longer necessary and has been removed. * self.multiworld.get_location -> self.get_location Changed the occurences in the modified code. --- worlds/raft/__init__.py | 68 +++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/worlds/raft/__init__.py b/worlds/raft/__init__.py index 71d5d1c7e44b..3e33b417c04b 100644 --- a/worlds/raft/__init__.py +++ b/worlds/raft/__init__.py @@ -57,10 +57,6 @@ def create_items(self): frequencyItems.append(raft_item) else: pool.append(raft_item) - if isFillingFrequencies: - if not hasattr(self.multiworld, "raft_frequencyItemsPerPlayer"): - self.multiworld.raft_frequencyItemsPerPlayer = {} - self.multiworld.raft_frequencyItemsPerPlayer[self.player] = frequencyItems extraItemNamePool = [] extras = len(location_table) - len(item_table) - 1 # Victory takes up 1 unaccounted-for slot @@ -109,17 +105,15 @@ def create_items(self): self.multiworld.get_location("Utopia Complete", self.player).place_locked_item( RaftItem("Victory", ItemClassification.progression, None, player=self.player)) + if frequencyItems: + self.place_frequencyItems(frequencyItems) + def set_rules(self): set_rules(self.multiworld, self.player) def create_regions(self): create_regions(self.multiworld, self.player) - def get_pre_fill_items(self): - if self.options.island_frequency_locations.is_filling_frequencies_in_world(): - return [loc.item for loc in self.multiworld.get_filled_locations()] - return [] - def create_item_replaceAsNecessary(self, name: str) -> Item: isFrequency = "Frequency" in name shouldUseProgressive = bool((isFrequency and self.options.island_frequency_locations == self.options.island_frequency_locations.option_progressive) @@ -152,23 +146,34 @@ def collect_item(self, state, item, remove=False): return super(RaftWorld, self).collect_item(state, item, remove) - def pre_fill(self): + def place_frequencyItems(self, frequencyItems): + def setLocationItem(location: str, itemName: str): + itemToUse = next(filter(lambda itm: itm.name == itemName, frequencyItems)) + frequencyItems.remove(itemToUse) + self.get_location(location).place_locked_item(itemToUse) + + def setLocationItemFromRegion(region: str, itemName: str): + itemToUse = next(filter(lambda itm: itm.name == itemName, frequencyItems)) + frequencyItems.remove(itemToUse) + location = self.random.choice(list(loc for loc in location_table if loc["region"] == region)) + self.get_location(location["name"]).place_locked_item(itemToUse) + if self.options.island_frequency_locations == self.options.island_frequency_locations.option_vanilla: - self.setLocationItem("Radio Tower Frequency to Vasagatan", "Vasagatan Frequency") - self.setLocationItem("Vasagatan Frequency to Balboa", "Balboa Island Frequency") - self.setLocationItem("Relay Station quest", "Caravan Island Frequency") - self.setLocationItem("Caravan Island Frequency to Tangaroa", "Tangaroa Frequency") - self.setLocationItem("Tangaroa Frequency to Varuna Point", "Varuna Point Frequency") - self.setLocationItem("Varuna Point Frequency to Temperance", "Temperance Frequency") - self.setLocationItem("Temperance Frequency to Utopia", "Utopia Frequency") + setLocationItem("Radio Tower Frequency to Vasagatan", "Vasagatan Frequency") + setLocationItem("Vasagatan Frequency to Balboa", "Balboa Island Frequency") + setLocationItem("Relay Station quest", "Caravan Island Frequency") + setLocationItem("Caravan Island Frequency to Tangaroa", "Tangaroa Frequency") + setLocationItem("Tangaroa Frequency to Varuna Point", "Varuna Point Frequency") + setLocationItem("Varuna Point Frequency to Temperance", "Temperance Frequency") + setLocationItem("Temperance Frequency to Utopia", "Utopia Frequency") elif self.options.island_frequency_locations == self.options.island_frequency_locations.option_random_on_island: - self.setLocationItemFromRegion("RadioTower", "Vasagatan Frequency") - self.setLocationItemFromRegion("Vasagatan", "Balboa Island Frequency") - self.setLocationItemFromRegion("BalboaIsland", "Caravan Island Frequency") - self.setLocationItemFromRegion("CaravanIsland", "Tangaroa Frequency") - self.setLocationItemFromRegion("Tangaroa", "Varuna Point Frequency") - self.setLocationItemFromRegion("Varuna Point", "Temperance Frequency") - self.setLocationItemFromRegion("Temperance", "Utopia Frequency") + setLocationItemFromRegion("RadioTower", "Vasagatan Frequency") + setLocationItemFromRegion("Vasagatan", "Balboa Island Frequency") + setLocationItemFromRegion("BalboaIsland", "Caravan Island Frequency") + setLocationItemFromRegion("CaravanIsland", "Tangaroa Frequency") + setLocationItemFromRegion("Tangaroa", "Varuna Point Frequency") + setLocationItemFromRegion("Varuna Point", "Temperance Frequency") + setLocationItemFromRegion("Temperance", "Utopia Frequency") elif self.options.island_frequency_locations in [ self.options.island_frequency_locations.option_random_island_order, self.options.island_frequency_locations.option_random_on_island_random_order @@ -201,22 +206,11 @@ def pre_fill(self): currentLocation = availableLocationList[0] # Utopia (only one left in list) availableLocationList.remove(currentLocation) if self.options.island_frequency_locations == self.options.island_frequency_locations.option_random_island_order: - self.setLocationItem(locationToVanillaFrequencyLocationMap[previousLocation], locationToFrequencyItemMap[currentLocation]) + setLocationItem(locationToVanillaFrequencyLocationMap[previousLocation], locationToFrequencyItemMap[currentLocation]) elif self.options.island_frequency_locations == self.options.island_frequency_locations.option_random_on_island_random_order: - self.setLocationItemFromRegion(previousLocation, locationToFrequencyItemMap[currentLocation]) + setLocationItemFromRegion(previousLocation, locationToFrequencyItemMap[currentLocation]) previousLocation = currentLocation - def setLocationItem(self, location: str, itemName: str): - itemToUse = next(filter(lambda itm: itm.name == itemName, self.multiworld.raft_frequencyItemsPerPlayer[self.player])) - self.multiworld.raft_frequencyItemsPerPlayer[self.player].remove(itemToUse) - self.multiworld.get_location(location, self.player).place_locked_item(itemToUse) - - def setLocationItemFromRegion(self, region: str, itemName: str): - itemToUse = next(filter(lambda itm: itm.name == itemName, self.multiworld.raft_frequencyItemsPerPlayer[self.player])) - self.multiworld.raft_frequencyItemsPerPlayer[self.player].remove(itemToUse) - location = self.random.choice(list(loc for loc in location_table if loc["region"] == region)) - self.multiworld.get_location(location["name"], self.player).place_locked_item(itemToUse) - def fill_slot_data(self): return { "IslandGenerationDistance": self.options.island_generation_distance.value, From 5b4d7c752670b9cdf79258792e8045d968a54e96 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sun, 8 Dec 2024 19:58:49 -0500 Subject: [PATCH 002/144] TUNIC: Add Shield to Ladder Storage logic (#4146) --- worlds/tunic/__init__.py | 5 +++++ worlds/tunic/options.py | 2 +- worlds/tunic/rules.py | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index d1430aac1895..4c62b18b140f 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -286,6 +286,11 @@ def remove_filler(amount: int) -> None: tunic_items.append(self.create_item(page, ItemClassification.progression | ItemClassification.useful)) items_to_create[page] = 0 + # logically relevant if you have ladder storage enabled + if self.options.ladder_storage and not self.options.ladder_storage_without_items: + tunic_items.append(self.create_item("Shield", ItemClassification.progression)) + items_to_create["Shield"] = 0 + if self.options.maskless: tunic_items.append(self.create_item("Scavenger Mask", ItemClassification.useful)) items_to_create["Scavenger Mask"] = 0 diff --git a/worlds/tunic/options.py b/worlds/tunic/options.py index cdd37a889461..f1d53362f4c9 100644 --- a/worlds/tunic/options.py +++ b/worlds/tunic/options.py @@ -216,7 +216,7 @@ class LadderStorage(Choice): class LadderStorageWithoutItems(Toggle): """ - If disabled, you logically require Stick, Sword, or Magic Orb to perform Ladder Storage. + If disabled, you logically require Stick, Sword, Magic Orb, or Shield to perform Ladder Storage. If enabled, you will be expected to perform Ladder Storage without progression items. This can be done with the plushie code, a Golden Coin, Prayer, and many other options. diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index aa69666daeb6..58c987acbcee 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -18,6 +18,7 @@ prayer = "Pages 24-25 (Prayer)" holy_cross = "Pages 42-43 (Holy Cross)" icebolt = "Pages 52-53 (Icebolt)" +shield = "Shield" key = "Key" house_key = "Old House Key" vault_key = "Fortress Vault Key" @@ -82,7 +83,7 @@ def can_ladder_storage(state: CollectionState, world: "TunicWorld") -> bool: return False if world.options.ladder_storage_without_items: return True - return has_stick(state, world.player) or state.has(grapple, world.player) + return has_stick(state, world.player) or state.has_any((grapple, shield), world.player) def has_mask(state: CollectionState, world: "TunicWorld") -> bool: From 1f712d9a8754103e2bbeb13075f460ff366d55df Mon Sep 17 00:00:00 2001 From: qwint Date: Sun, 8 Dec 2024 19:59:40 -0500 Subject: [PATCH 003/144] Various Worlds: use / explicitly for pkgutil (#4232) --- worlds/kdl3/regions.py | 2 +- worlds/kdl3/rom.py | 8 ++++---- worlds/ladx/LADXR/patches/bank34.py | 2 +- worlds/ladx/LADXR/patches/bank3e.py | 2 +- worlds/lingo/static_logic.py | 2 +- worlds/minecraft/Constants.py | 2 +- worlds/mm2/rom.py | 2 +- worlds/shivers/Constants.py | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/worlds/kdl3/regions.py b/worlds/kdl3/regions.py index c47e5dee4095..af5208d365f0 100644 --- a/worlds/kdl3/regions.py +++ b/worlds/kdl3/regions.py @@ -57,7 +57,7 @@ def generate_valid_level(world: "KDL3World", level: int, stage: int, def generate_rooms(world: "KDL3World", level_regions: Dict[int, Region]) -> None: level_names = {location_name.level_names[level]: level for level in location_name.level_names} - room_data = orjson.loads(get_data(__name__, os.path.join("data", "Rooms.json"))) + room_data = orjson.loads(get_data(__name__, "data/Rooms.json")) rooms: Dict[str, KDL3Room] = dict() for room_entry in room_data: room = KDL3Room(room_entry["name"], world.player, world.multiworld, None, room_entry["level"], diff --git a/worlds/kdl3/rom.py b/worlds/kdl3/rom.py index 3dd10ce1c43f..741ea0083027 100644 --- a/worlds/kdl3/rom.py +++ b/worlds/kdl3/rom.py @@ -313,7 +313,7 @@ def handle_level_sprites(stages: List[Tuple[int, ...]], sprites: List[bytearray] def write_heart_star_sprites(rom: RomData) -> None: compressed = rom.read_bytes(heart_star_address, heart_star_size) decompressed = hal_decompress(compressed) - patch = get_data(__name__, os.path.join("data", "APHeartStar.bsdiff4")) + patch = get_data(__name__, "data/APHeartStar.bsdiff4") patched = bytearray(bsdiff4.patch(decompressed, patch)) rom.write_bytes(0x1AF7DF, patched) patched[0:0] = [0xE3, 0xFF] @@ -327,10 +327,10 @@ def write_consumable_sprites(rom: RomData, consumables: bool, stars: bool) -> No decompressed = hal_decompress(compressed) patched = bytearray(decompressed) if consumables: - patch = get_data(__name__, os.path.join("data", "APConsumable.bsdiff4")) + patch = get_data(__name__, "data/APConsumable.bsdiff4") patched = bytearray(bsdiff4.patch(bytes(patched), patch)) if stars: - patch = get_data(__name__, os.path.join("data", "APStars.bsdiff4")) + patch = get_data(__name__, "data/APStars.bsdiff4") patched = bytearray(bsdiff4.patch(bytes(patched), patch)) patched[0:0] = [0xE3, 0xFF] patched.append(0xFF) @@ -380,7 +380,7 @@ def get_source_data(cls) -> bytes: def patch_rom(world: "KDL3World", patch: KDL3ProcedurePatch) -> None: patch.write_file("kdl3_basepatch.bsdiff4", - get_data(__name__, os.path.join("data", "kdl3_basepatch.bsdiff4"))) + get_data(__name__, "data/kdl3_basepatch.bsdiff4")) # Write open world patch if world.options.open_world: diff --git a/worlds/ladx/LADXR/patches/bank34.py b/worlds/ladx/LADXR/patches/bank34.py index 31b9ca124436..e88727e868c6 100644 --- a/worlds/ladx/LADXR/patches/bank34.py +++ b/worlds/ladx/LADXR/patches/bank34.py @@ -75,7 +75,7 @@ def addBank34(rom, item_list): .notCavesA: add hl, de ret - """ + pkgutil.get_data(__name__, os.path.join("bank3e.asm", "message.asm")).decode().replace("\r", ""), 0x4000), fill_nop=True) + """ + pkgutil.get_data(__name__, "bank3e.asm/message.asm").decode().replace("\r", ""), 0x4000), fill_nop=True) nextItemLookup = ItemNameStringBufferStart nameLookup = { diff --git a/worlds/ladx/LADXR/patches/bank3e.py b/worlds/ladx/LADXR/patches/bank3e.py index 7e690349a335..632fffa7e63e 100644 --- a/worlds/ladx/LADXR/patches/bank3e.py +++ b/worlds/ladx/LADXR/patches/bank3e.py @@ -56,7 +56,7 @@ def addBank3E(rom, seed, player_id, player_name_list): """)) def get_asm(name): - return pkgutil.get_data(__name__, os.path.join("bank3e.asm", name)).decode().replace("\r", "") + return pkgutil.get_data(__name__, "bank3e.asm/" + name).decode().replace("\r", "") rom.patch(0x3E, 0x0000, 0x2F00, ASM(""" call MainJumpTable diff --git a/worlds/lingo/static_logic.py b/worlds/lingo/static_logic.py index 74eea449f228..9925e9582a2c 100644 --- a/worlds/lingo/static_logic.py +++ b/worlds/lingo/static_logic.py @@ -107,7 +107,7 @@ def find_class(self, module, name): return getattr(safe_builtins, name) raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") - file = pkgutil.get_data(__name__, os.path.join("data", "generated.dat")) + file = pkgutil.get_data(__name__, "data/generated.dat") pickdata = RenameUnpickler(BytesIO(file)).load() HASHES.update(pickdata["HASHES"]) diff --git a/worlds/minecraft/Constants.py b/worlds/minecraft/Constants.py index 0d1101e802fd..1f7b6fa6acef 100644 --- a/worlds/minecraft/Constants.py +++ b/worlds/minecraft/Constants.py @@ -3,7 +3,7 @@ import pkgutil def load_data_file(*args) -> dict: - fname = os.path.join("data", *args) + fname = "/".join(["data", *args]) return json.loads(pkgutil.get_data(__name__, fname).decode()) # For historical reasons, these values are different. diff --git a/worlds/mm2/rom.py b/worlds/mm2/rom.py index cac0a8706007..e37c5bc2a148 100644 --- a/worlds/mm2/rom.py +++ b/worlds/mm2/rom.py @@ -126,7 +126,7 @@ def write_bytes(self, offset: int, value: Iterable[int]) -> None: def patch_rom(world: "MM2World", patch: MM2ProcedurePatch) -> None: - patch.write_file("mm2_basepatch.bsdiff4", pkgutil.get_data(__name__, os.path.join("data", "mm2_basepatch.bsdiff4"))) + patch.write_file("mm2_basepatch.bsdiff4", pkgutil.get_data(__name__, "data/mm2_basepatch.bsdiff4")) # text writing patch.write_bytes(0x37E2A, MM2TextEntry("FOR ", 0xCB).resolve()) patch.write_bytes(0x37EAA, MM2TextEntry("GET EQUIPPED ", 0x0B).resolve()) diff --git a/worlds/shivers/Constants.py b/worlds/shivers/Constants.py index 0b00cecec3ec..95b3c2d56ad9 100644 --- a/worlds/shivers/Constants.py +++ b/worlds/shivers/Constants.py @@ -3,7 +3,7 @@ import pkgutil def load_data_file(*args) -> dict: - fname = os.path.join("data", *args) + fname = "/".join(["data", *args]) return json.loads(pkgutil.get_data(__name__, fname).decode()) location_id_offset: int = 27000 From 26f9720e69d33af5f55950d4bc197460f39df3ab Mon Sep 17 00:00:00 2001 From: Louis M Date: Sun, 8 Dec 2024 20:18:00 -0500 Subject: [PATCH 004/144] Aquaria: mega refactoring (#3810) This PR is mainly refactoring. Here is what changed: - Changing item names so that each words are capitalized (`Energy Form` instead of `Energy form`) - Removing duplication of string literal by using: - Constants for items and locations, - Region's name attribute for entrances, - Clarify some documentations, - Adding some region to be more representative of the game and to remove listing of locations in the rules (prioritize entrance rules over individual location rules). This is the other minor modifications that are not refactoring: - Adding an early bind song option since that can be used to exit starting area. - Changing Sun God to Lumerean God to be coherent with the other gods. - Changing Home Water to Home Waters and Open Water to Open Waters to be coherent with the game. - Removing a rules to have an attack to go in Mithalas Cathedral since you can to get some checks in it without an attack. - Adding some options to slot data to be used with Poptracker. - Fixing a little but still potentially logic breaking bug. --- worlds/aquaria/Items.py | 440 ++++-- worlds/aquaria/Locations.py | 800 +++++++---- worlds/aquaria/Options.py | 52 +- worlds/aquaria/Regions.py | 1190 ++++++++--------- worlds/aquaria/__init__.py | 82 +- worlds/aquaria/docs/en_Aquaria.md | 2 +- worlds/aquaria/test/__init__.py | 405 +++--- worlds/aquaria/test/test_beast_form_access.py | 24 +- ...test_beast_form_or_arnassi_armor_access.py | 46 +- worlds/aquaria/test/test_bind_song_access.py | 33 +- .../test/test_bind_song_option_access.py | 40 +- .../aquaria/test/test_confined_home_water.py | 9 +- worlds/aquaria/test/test_dual_song_access.py | 17 +- .../aquaria/test/test_energy_form_access.py | 29 +- .../test_energy_form_or_dual_form_access.py | 132 +- worlds/aquaria/test/test_fish_form_access.py | 39 +- worlds/aquaria/test/test_li_song_access.py | 55 +- worlds/aquaria/test/test_light_access.py | 99 +- .../aquaria/test/test_nature_form_access.py | 79 +- ...st_no_progression_hard_hidden_locations.py | 51 +- .../test_progression_hard_hidden_locations.py | 51 +- .../aquaria/test/test_spirit_form_access.py | 38 +- worlds/aquaria/test/test_sun_form_access.py | 24 +- .../test_unconfine_home_water_via_both.py | 9 +- ...st_unconfine_home_water_via_energy_door.py | 9 +- ...st_unconfine_home_water_via_transturtle.py | 9 +- 26 files changed, 2119 insertions(+), 1645 deletions(-) diff --git a/worlds/aquaria/Items.py b/worlds/aquaria/Items.py index f822d675e6e7..88ac7c76e0a3 100644 --- a/worlds/aquaria/Items.py +++ b/worlds/aquaria/Items.py @@ -59,156 +59,316 @@ class ItemData: type: ItemType group: ItemGroup - def __init__(self, id: int, count: int, type: ItemType, group: ItemGroup): + def __init__(self, aId: int, count: int, aType: ItemType, group: ItemGroup): """ Initialisation of the item data - @param id: The item ID + @param aId: The item ID @param count: the number of items in the pool - @param type: the importance type of the item + @param aType: the importance type of the item @param group: the usage of the item in the game """ - self.id = id + self.id = aId self.count = count - self.type = type + self.type = aType self.group = group +class ItemNames: + """ + Constants used to represent the mane of every items. + """ + # Normal items + ANEMONE = "Anemone" + ARNASSI_STATUE = "Arnassi Statue" + BIG_SEED = "Big Seed" + GLOWING_SEED = "Glowing Seed" + BLACK_PEARL = "Black Pearl" + BABY_BLASTER = "Baby Blaster" + CRAB_ARMOR = "Crab Armor" + BABY_DUMBO = "Baby Dumbo" + TOOTH = "Tooth" + ENERGY_STATUE = "Energy Statue" + KROTITE_ARMOR = "Krotite Armor" + GOLDEN_STARFISH = "Golden Starfish" + GOLDEN_GEAR = "Golden Gear" + JELLY_BEACON = "Jelly Beacon" + JELLY_COSTUME = "Jelly Costume" + JELLY_PLANT = "Jelly Plant" + MITHALAS_DOLL = "Mithalas Doll" + MITHALAN_DRESS = "Mithalan Dress" + MITHALAS_BANNER = "Mithalas Banner" + MITHALAS_POT = "Mithalas Pot" + MUTANT_COSTUME = "Mutant Costume" + BABY_NAUTILUS = "Baby Nautilus" + BABY_PIRANHA = "Baby Piranha" + ARNASSI_ARMOR = "Arnassi Armor" + SEED_BAG = "Seed Bag" + KING_S_SKULL = "King's Skull" + SONG_PLANT_SPORE = "Song Plant Spore" + STONE_HEAD = "Stone Head" + SUN_KEY = "Sun Key" + GIRL_COSTUME = "Girl Costume" + ODD_CONTAINER = "Odd Container" + TRIDENT = "Trident" + TURTLE_EGG = "Turtle Egg" + JELLY_EGG = "Jelly Egg" + URCHIN_COSTUME = "Urchin Costume" + BABY_WALKER = "Baby Walker" + VEDHA_S_CURE_ALL = "Vedha's Cure-All" + ZUUNA_S_PEROGI = "Zuuna's Perogi" + ARCANE_POULTICE = "Arcane Poultice" + BERRY_ICE_CREAM = "Berry Ice Cream" + BUTTERY_SEA_LOAF = "Buttery Sea Loaf" + COLD_BORSCHT = "Cold Borscht" + COLD_SOUP = "Cold Soup" + CRAB_CAKE = "Crab Cake" + DIVINE_SOUP = "Divine Soup" + DUMBO_ICE_CREAM = "Dumbo Ice Cream" + FISH_OIL = "Fish Oil" + GLOWING_EGG = "Glowing Egg" + HAND_ROLL = "Hand Roll" + HEALING_POULTICE = "Healing Poultice" + HEARTY_SOUP = "Hearty Soup" + HOT_BORSCHT = "Hot Borscht" + HOT_SOUP = "Hot Soup" + ICE_CREAM = "Ice Cream" + LEADERSHIP_ROLL = "Leadership Roll" + LEAF_POULTICE = "Leaf Poultice" + LEECHING_POULTICE = "Leeching Poultice" + LEGENDARY_CAKE = "Legendary Cake" + LOAF_OF_LIFE = "Loaf of Life" + LONG_LIFE_SOUP = "Long Life Soup" + MAGIC_SOUP = "Magic Soup" + MUSHROOM_X_2 = "Mushroom x 2" + PEROGI = "Perogi" + PLANT_LEAF = "Plant Leaf" + PLUMP_PEROGI = "Plump Perogi" + POISON_LOAF = "Poison Loaf" + POISON_SOUP = "Poison Soup" + RAINBOW_MUSHROOM = "Rainbow Mushroom" + RAINBOW_SOUP = "Rainbow Soup" + RED_BERRY = "Red Berry" + RED_BULB_X_2 = "Red Bulb x 2" + ROTTEN_CAKE = "Rotten Cake" + ROTTEN_LOAF_X_8 = "Rotten Loaf x 8" + ROTTEN_MEAT = "Rotten Meat" + ROYAL_SOUP = "Royal Soup" + SEA_CAKE = "Sea Cake" + SEA_LOAF = "Sea Loaf" + SHARK_FIN_SOUP = "Shark Fin Soup" + SIGHT_POULTICE = "Sight Poultice" + SMALL_BONE_X_2 = "Small Bone x 2" + SMALL_EGG = "Small Egg" + SMALL_TENTACLE_X_2 = "Small Tentacle x 2" + SPECIAL_BULB = "Special Bulb" + SPECIAL_CAKE = "Special Cake" + SPICY_MEAT_X_2 = "Spicy Meat x 2" + SPICY_ROLL = "Spicy Roll" + SPICY_SOUP = "Spicy Soup" + SPIDER_ROLL = "Spider Roll" + SWAMP_CAKE = "Swamp Cake" + TASTY_CAKE = "Tasty Cake" + TASTY_ROLL = "Tasty Roll" + TOUGH_CAKE = "Tough Cake" + TURTLE_SOUP = "Turtle Soup" + VEDHA_SEA_CRISP = "Vedha Sea Crisp" + VEGGIE_CAKE = "Veggie Cake" + VEGGIE_ICE_CREAM = "Veggie Ice Cream" + VEGGIE_SOUP = "Veggie Soup" + VOLCANO_ROLL = "Volcano Roll" + HEALTH_UPGRADE = "Health Upgrade" + WOK = "Wok" + EEL_OIL_X_2 = "Eel Oil x 2" + FISH_MEAT_X_2 = "Fish Meat x 2" + FISH_OIL_X_3 = "Fish Oil x 3" + GLOWING_EGG_X_2 = "Glowing Egg x 2" + HEALING_POULTICE_X_2 = "Healing Poultice x 2" + HOT_SOUP_X_2 = "Hot Soup x 2" + LEADERSHIP_ROLL_X_2 = "Leadership Roll x 2" + LEAF_POULTICE_X_3 = "Leaf Poultice x 3" + PLANT_LEAF_X_2 = "Plant Leaf x 2" + PLANT_LEAF_X_3 = "Plant Leaf x 3" + ROTTEN_MEAT_X_2 = "Rotten Meat x 2" + ROTTEN_MEAT_X_8 = "Rotten Meat x 8" + SEA_LOAF_X_2 = "Sea Loaf x 2" + SMALL_BONE_X_3 = "Small Bone x 3" + SMALL_EGG_X_2 = "Small Egg x 2" + LI_AND_LI_SONG = "Li and Li Song" + SHIELD_SONG = "Shield Song" + BEAST_FORM = "Beast Form" + SUN_FORM = "Sun Form" + NATURE_FORM = "Nature Form" + ENERGY_FORM = "Energy Form" + BIND_SONG = "Bind Song" + FISH_FORM = "Fish Form" + SPIRIT_FORM = "Spirit Form" + DUAL_FORM = "Dual Form" + TRANSTURTLE_VEIL_TOP_LEFT = "Transturtle Veil top left" + TRANSTURTLE_VEIL_TOP_RIGHT = "Transturtle Veil top right" + TRANSTURTLE_OPEN_WATERS = "Transturtle Open Waters top right" + TRANSTURTLE_KELP_FOREST = "Transturtle Kelp Forest bottom left" + TRANSTURTLE_HOME_WATERS = "Transturtle Home Waters" + TRANSTURTLE_ABYSS = "Transturtle Abyss right" + TRANSTURTLE_BODY = "Transturtle Final Boss" + TRANSTURTLE_SIMON_SAYS = "Transturtle Simon Says" + TRANSTURTLE_ARNASSI_RUINS = "Transturtle Arnassi Ruins" + # Events name + BODY_TONGUE_CLEARED = "Body Tongue cleared" + HAS_SUN_CRYSTAL = "Has Sun Crystal" + FALLEN_GOD_BEATED = "Fallen God beated" + MITHALAN_GOD_BEATED = "Mithalan God beated" + DRUNIAN_GOD_BEATED = "Drunian God beated" + LUMEREAN_GOD_BEATED = "Lumerean God beated" + THE_GOLEM_BEATED = "The Golem beated" + NAUTILUS_PRIME_BEATED = "Nautilus Prime beated" + BLASTER_PEG_PRIME_BEATED = "Blaster Peg Prime beated" + MERGOG_BEATED = "Mergog beated" + MITHALAN_PRIESTS_BEATED = "Mithalan priests beated" + OCTOPUS_PRIME_BEATED = "Octopus Prime beated" + CRABBIUS_MAXIMUS_BEATED = "Crabbius Maximus beated" + MANTIS_SHRIMP_PRIME_BEATED = "Mantis Shrimp Prime beated" + KING_JELLYFISH_GOD_PRIME_BEATED = "King Jellyfish God Prime beated" + VICTORY = "Victory" + FIRST_SECRET_OBTAINED = "First Secret obtained" + SECOND_SECRET_OBTAINED = "Second Secret obtained" + THIRD_SECRET_OBTAINED = "Third Secret obtained" """Information data for every (not event) item.""" item_table = { # name: ID, Nb, Item Type, Item Group - "Anemone": ItemData(698000, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_anemone - "Arnassi Statue": ItemData(698001, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_arnassi_statue - "Big Seed": ItemData(698002, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_big_seed - "Glowing Seed": ItemData(698003, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_bio_seed - "Black Pearl": ItemData(698004, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_blackpearl - "Baby Blaster": ItemData(698005, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_blaster - "Crab Armor": ItemData(698006, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_crab_costume - "Baby Dumbo": ItemData(698007, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_dumbo - "Tooth": ItemData(698008, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_boss - "Energy Statue": ItemData(698009, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_statue - "Krotite Armor": ItemData(698010, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_temple - "Golden Starfish": ItemData(698011, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_gold_star - "Golden Gear": ItemData(698012, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_golden_gear - "Jelly Beacon": ItemData(698013, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_jelly_beacon - "Jelly Costume": ItemData(698014, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_jelly_costume - "Jelly Plant": ItemData(698015, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_jelly_plant - "Mithalas Doll": ItemData(698016, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithala_doll - "Mithalan Dress": ItemData(698017, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalan_costume - "Mithalas Banner": ItemData(698018, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_banner - "Mithalas Pot": ItemData(698019, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_pot - "Mutant Costume": ItemData(698020, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mutant_costume - "Baby Nautilus": ItemData(698021, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_nautilus - "Baby Piranha": ItemData(698022, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_piranha - "Arnassi Armor": ItemData(698023, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_seahorse_costume - "Seed Bag": ItemData(698024, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_seed_bag - "King's Skull": ItemData(698025, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_skull - "Song Plant Spore": ItemData(698026, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_spore_seed - "Stone Head": ItemData(698027, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_stone_head - "Sun Key": ItemData(698028, 1, ItemType.NORMAL, ItemGroup.COLLECTIBLE), # collectible_sun_key - "Girl Costume": ItemData(698029, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_teen_costume - "Odd Container": ItemData(698030, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_treasure_chest - "Trident": ItemData(698031, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_trident_head - "Turtle Egg": ItemData(698032, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_turtle_egg - "Jelly Egg": ItemData(698033, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_upsidedown_seed - "Urchin Costume": ItemData(698034, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_urchin_costume - "Baby Walker": ItemData(698035, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_walker - "Vedha's Cure-All-All": ItemData(698036, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Vedha'sCure-All - "Zuuna's perogi": ItemData(698037, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Zuuna'sperogi - "Arcane poultice": ItemData(698038, 7, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_arcanepoultice - "Berry ice cream": ItemData(698039, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_berryicecream - "Buttery sea loaf": ItemData(698040, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_butterysealoaf - "Cold borscht": ItemData(698041, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_coldborscht - "Cold soup": ItemData(698042, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_coldsoup - "Crab cake": ItemData(698043, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_crabcake - "Divine soup": ItemData(698044, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_divinesoup - "Dumbo ice cream": ItemData(698045, 3, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_dumboicecream - "Fish oil": ItemData(698046, 2, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_fishoil - "Glowing egg": ItemData(698047, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_glowingegg - "Hand roll": ItemData(698048, 5, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_handroll - "Healing poultice": ItemData(698049, 4, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_healingpoultice - "Hearty soup": ItemData(698050, 5, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_heartysoup - "Hot borscht": ItemData(698051, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_hotborscht - "Hot soup": ItemData(698052, 3, ItemType.PROGRESSION, ItemGroup.RECIPE), # ingredient_hotsoup - "Ice cream": ItemData(698053, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_icecream - "Leadership roll": ItemData(698054, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_leadershiproll - "Leaf poultice": ItemData(698055, 5, ItemType.PROGRESSION, ItemGroup.RECIPE), # ingredient_leafpoultice - "Leeching poultice": ItemData(698056, 4, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_leechingpoultice - "Legendary cake": ItemData(698057, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_legendarycake - "Loaf of life": ItemData(698058, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_loafoflife - "Long life soup": ItemData(698059, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_longlifesoup - "Magic soup": ItemData(698060, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_magicsoup - "Mushroom x 2": ItemData(698061, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_mushroom - "Perogi": ItemData(698062, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_perogi - "Plant leaf": ItemData(698063, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_plantleaf - "Plump perogi": ItemData(698064, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_plumpperogi - "Poison loaf": ItemData(698065, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_poisonloaf - "Poison soup": ItemData(698066, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_poisonsoup - "Rainbow mushroom": ItemData(698067, 4, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_rainbowmushroom - "Rainbow soup": ItemData(698068, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_rainbowsoup - "Red berry": ItemData(698069, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_redberry - "Red bulb x 2": ItemData(698070, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_redbulb - "Rotten cake": ItemData(698071, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_rottencake - "Rotten loaf x 8": ItemData(698072, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_rottenloaf - "Rotten meat": ItemData(698073, 5, ItemType.JUNK, ItemGroup.INGREDIENT), # ingredient_rottenmeat - "Royal soup": ItemData(698074, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_royalsoup - "Sea cake": ItemData(698075, 4, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_seacake - "Sea loaf": ItemData(698076, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_sealoaf - "Shark fin soup": ItemData(698077, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_sharkfinsoup - "Sight poultice": ItemData(698078, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_sightpoultice - "Small bone x 2": ItemData(698079, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallbone - "Small egg": ItemData(698080, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallegg - "Small tentacle x 2": ItemData(698081, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smalltentacle - "Special bulb": ItemData(698082, 5, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_specialbulb - "Special cake": ItemData(698083, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_specialcake - "Spicy meat x 2": ItemData(698084, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_spicymeat - "Spicy roll": ItemData(698085, 11, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_spicyroll - "Spicy soup": ItemData(698086, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_spicysoup - "Spider roll": ItemData(698087, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_spiderroll - "Swamp cake": ItemData(698088, 3, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_swampcake - "Tasty cake": ItemData(698089, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_tastycake - "Tasty roll": ItemData(698090, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_tastyroll - "Tough cake": ItemData(698091, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_toughcake - "Turtle soup": ItemData(698092, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_turtlesoup - "Vedha sea crisp": ItemData(698093, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_vedhaseacrisp - "Veggie cake": ItemData(698094, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_veggiecake - "Veggie ice cream": ItemData(698095, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_veggieicecream - "Veggie soup": ItemData(698096, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_veggiesoup - "Volcano roll": ItemData(698097, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_volcanoroll - "Health upgrade": ItemData(698098, 5, ItemType.NORMAL, ItemGroup.HEALTH), # upgrade_health_? - "Wok": ItemData(698099, 1, ItemType.NORMAL, ItemGroup.UTILITY), # upgrade_wok - "Eel oil x 2": ItemData(698100, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_eeloil - "Fish meat x 2": ItemData(698101, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_fishmeat - "Fish oil x 3": ItemData(698102, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_fishoil - "Glowing egg x 2": ItemData(698103, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_glowingegg - "Healing poultice x 2": ItemData(698104, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_healingpoultice - "Hot soup x 2": ItemData(698105, 1, ItemType.PROGRESSION, ItemGroup.RECIPE), # ingredient_hotsoup - "Leadership roll x 2": ItemData(698106, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_leadershiproll - "Leaf poultice x 3": ItemData(698107, 2, ItemType.PROGRESSION, ItemGroup.RECIPE), # ingredient_leafpoultice - "Plant leaf x 2": ItemData(698108, 2, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_plantleaf - "Plant leaf x 3": ItemData(698109, 4, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_plantleaf - "Rotten meat x 2": ItemData(698110, 1, ItemType.JUNK, ItemGroup.INGREDIENT), # ingredient_rottenmeat - "Rotten meat x 8": ItemData(698111, 1, ItemType.JUNK, ItemGroup.INGREDIENT), # ingredient_rottenmeat - "Sea loaf x 2": ItemData(698112, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_sealoaf - "Small bone x 3": ItemData(698113, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallbone - "Small egg x 2": ItemData(698114, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallegg - "Li and Li song": ItemData(698115, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_li - "Shield song": ItemData(698116, 1, ItemType.NORMAL, ItemGroup.SONG), # song_shield - "Beast form": ItemData(698117, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_beast - "Sun form": ItemData(698118, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_sun - "Nature form": ItemData(698119, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_nature - "Energy form": ItemData(698120, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_energy - "Bind song": ItemData(698121, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_bind - "Fish form": ItemData(698122, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_fish - "Spirit form": ItemData(698123, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_spirit - "Dual form": ItemData(698124, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_dual - "Transturtle Veil top left": ItemData(698125, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_veil01 - "Transturtle Veil top right": ItemData(698126, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_veil02 - "Transturtle Open Water top right": ItemData(698127, 1, ItemType.PROGRESSION, - ItemGroup.TURTLE), # transport_openwater03 - "Transturtle Forest bottom left": ItemData(698128, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_forest04 - "Transturtle Home Water": ItemData(698129, 1, ItemType.NORMAL, ItemGroup.TURTLE), # transport_mainarea - "Transturtle Abyss right": ItemData(698130, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_abyss03 - "Transturtle Final Boss": ItemData(698131, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_finalboss - "Transturtle Simon Says": ItemData(698132, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_forest05 - "Transturtle Arnassi Ruins": ItemData(698133, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_seahorse + ItemNames.ANEMONE: ItemData(698000, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_anemone + ItemNames.ARNASSI_STATUE: ItemData(698001, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_arnassi_statue + ItemNames.BIG_SEED: ItemData(698002, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_big_seed + ItemNames.GLOWING_SEED: ItemData(698003, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_bio_seed + ItemNames.BLACK_PEARL: ItemData(698004, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_blackpearl + ItemNames.BABY_BLASTER: ItemData(698005, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_blaster + ItemNames.CRAB_ARMOR: ItemData(698006, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_crab_costume + ItemNames.BABY_DUMBO: ItemData(698007, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_dumbo + ItemNames.TOOTH: ItemData(698008, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_boss + ItemNames.ENERGY_STATUE: ItemData(698009, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_statue + ItemNames.KROTITE_ARMOR: ItemData(698010, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_temple + ItemNames.GOLDEN_STARFISH: ItemData(698011, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_gold_star + ItemNames.GOLDEN_GEAR: ItemData(698012, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_golden_gear + ItemNames.JELLY_BEACON: ItemData(698013, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_jelly_beacon + ItemNames.JELLY_COSTUME: ItemData(698014, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_jelly_costume + ItemNames.JELLY_PLANT: ItemData(698015, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_jelly_plant + ItemNames.MITHALAS_DOLL: ItemData(698016, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithala_doll + ItemNames.MITHALAN_DRESS: ItemData(698017, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalan_costume + ItemNames.MITHALAS_BANNER: ItemData(698018, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_banner + ItemNames.MITHALAS_POT: ItemData(698019, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_pot + ItemNames.MUTANT_COSTUME: ItemData(698020, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mutant_costume + ItemNames.BABY_NAUTILUS: ItemData(698021, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_nautilus + ItemNames.BABY_PIRANHA: ItemData(698022, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_piranha + ItemNames.ARNASSI_ARMOR: ItemData(698023, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_seahorse_costume + ItemNames.SEED_BAG: ItemData(698024, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_seed_bag + ItemNames.KING_S_SKULL: ItemData(698025, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_skull + ItemNames.SONG_PLANT_SPORE: ItemData(698026, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_spore_seed + ItemNames.STONE_HEAD: ItemData(698027, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_stone_head + ItemNames.SUN_KEY: ItemData(698028, 1, ItemType.NORMAL, ItemGroup.COLLECTIBLE), # collectible_sun_key + ItemNames.GIRL_COSTUME: ItemData(698029, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_teen_costume + ItemNames.ODD_CONTAINER: ItemData(698030, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_treasure_chest + ItemNames.TRIDENT: ItemData(698031, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_trident_head + ItemNames.TURTLE_EGG: ItemData(698032, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_turtle_egg + ItemNames.JELLY_EGG: ItemData(698033, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_upsidedown_seed + ItemNames.URCHIN_COSTUME: ItemData(698034, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_urchin_costume + ItemNames.BABY_WALKER: ItemData(698035, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_walker + ItemNames.VEDHA_S_CURE_ALL: ItemData(698036, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Vedha'sCure-All + ItemNames.ZUUNA_S_PEROGI: ItemData(698037, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Zuuna'sperogi + ItemNames.ARCANE_POULTICE: ItemData(698038, 7, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_arcanepoultice + ItemNames.BERRY_ICE_CREAM: ItemData(698039, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_berryicecream + ItemNames.BUTTERY_SEA_LOAF: ItemData(698040, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_butterysealoaf + ItemNames.COLD_BORSCHT: ItemData(698041, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_coldborscht + ItemNames.COLD_SOUP: ItemData(698042, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_coldsoup + ItemNames.CRAB_CAKE: ItemData(698043, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_crabcake + ItemNames.DIVINE_SOUP: ItemData(698044, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_divinesoup + ItemNames.DUMBO_ICE_CREAM: ItemData(698045, 3, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_dumboicecream + ItemNames.FISH_OIL: ItemData(698046, 2, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_fishoil + ItemNames.GLOWING_EGG: ItemData(698047, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_glowingegg + ItemNames.HAND_ROLL: ItemData(698048, 5, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_handroll + ItemNames.HEALING_POULTICE: ItemData(698049, 4, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_healingpoultice + ItemNames.HEARTY_SOUP: ItemData(698050, 5, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_heartysoup + ItemNames.HOT_BORSCHT: ItemData(698051, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_hotborscht + ItemNames.HOT_SOUP: ItemData(698052, 3, ItemType.PROGRESSION, ItemGroup.RECIPE), # ingredient_hotsoup + ItemNames.ICE_CREAM: ItemData(698053, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_icecream + ItemNames.LEADERSHIP_ROLL: ItemData(698054, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_leadershiproll + ItemNames.LEAF_POULTICE: ItemData(698055, 5, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_leafpoultice + ItemNames.LEECHING_POULTICE: ItemData(698056, 4, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_leechingpoultice + ItemNames.LEGENDARY_CAKE: ItemData(698057, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_legendarycake + ItemNames.LOAF_OF_LIFE: ItemData(698058, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_loafoflife + ItemNames.LONG_LIFE_SOUP: ItemData(698059, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_longlifesoup + ItemNames.MAGIC_SOUP: ItemData(698060, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_magicsoup + ItemNames.MUSHROOM_X_2: ItemData(698061, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_mushroom + ItemNames.PEROGI: ItemData(698062, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_perogi + ItemNames.PLANT_LEAF: ItemData(698063, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_plantleaf + ItemNames.PLUMP_PEROGI: ItemData(698064, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_plumpperogi + ItemNames.POISON_LOAF: ItemData(698065, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_poisonloaf + ItemNames.POISON_SOUP: ItemData(698066, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_poisonsoup + ItemNames.RAINBOW_MUSHROOM: ItemData(698067, 4, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_rainbowmushroom + ItemNames.RAINBOW_SOUP: ItemData(698068, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_rainbowsoup + ItemNames.RED_BERRY: ItemData(698069, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_redberry + ItemNames.RED_BULB_X_2: ItemData(698070, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_redbulb + ItemNames.ROTTEN_CAKE: ItemData(698071, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_rottencake + ItemNames.ROTTEN_LOAF_X_8: ItemData(698072, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_rottenloaf + ItemNames.ROTTEN_MEAT: ItemData(698073, 5, ItemType.JUNK, ItemGroup.INGREDIENT), # ingredient_rottenmeat + ItemNames.ROYAL_SOUP: ItemData(698074, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_royalsoup + ItemNames.SEA_CAKE: ItemData(698075, 4, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_seacake + ItemNames.SEA_LOAF: ItemData(698076, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_sealoaf + ItemNames.SHARK_FIN_SOUP: ItemData(698077, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_sharkfinsoup + ItemNames.SIGHT_POULTICE: ItemData(698078, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_sightpoultice + ItemNames.SMALL_BONE_X_2: ItemData(698079, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallbone + ItemNames.SMALL_EGG: ItemData(698080, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallegg + ItemNames.SMALL_TENTACLE_X_2: ItemData(698081, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smalltentacle + ItemNames.SPECIAL_BULB: ItemData(698082, 5, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_specialbulb + ItemNames.SPECIAL_CAKE: ItemData(698083, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_specialcake + ItemNames.SPICY_MEAT_X_2: ItemData(698084, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_spicymeat + ItemNames.SPICY_ROLL: ItemData(698085, 11, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_spicyroll + ItemNames.SPICY_SOUP: ItemData(698086, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_spicysoup + ItemNames.SPIDER_ROLL: ItemData(698087, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_spiderroll + ItemNames.SWAMP_CAKE: ItemData(698088, 3, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_swampcake + ItemNames.TASTY_CAKE: ItemData(698089, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_tastycake + ItemNames.TASTY_ROLL: ItemData(698090, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_tastyroll + ItemNames.TOUGH_CAKE: ItemData(698091, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_toughcake + ItemNames.TURTLE_SOUP: ItemData(698092, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_turtlesoup + ItemNames.VEDHA_SEA_CRISP: ItemData(698093, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_vedhaseacrisp + ItemNames.VEGGIE_CAKE: ItemData(698094, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_veggiecake + ItemNames.VEGGIE_ICE_CREAM: ItemData(698095, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_veggieicecream + ItemNames.VEGGIE_SOUP: ItemData(698096, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_veggiesoup + ItemNames.VOLCANO_ROLL: ItemData(698097, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_volcanoroll + ItemNames.HEALTH_UPGRADE: ItemData(698098, 5, ItemType.NORMAL, ItemGroup.HEALTH), # upgrade_health_? + ItemNames.WOK: ItemData(698099, 1, ItemType.NORMAL, ItemGroup.UTILITY), # upgrade_wok + ItemNames.EEL_OIL_X_2: ItemData(698100, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_eeloil + ItemNames.FISH_MEAT_X_2: ItemData(698101, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_fishmeat + ItemNames.FISH_OIL_X_3: ItemData(698102, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_fishoil + ItemNames.GLOWING_EGG_X_2: ItemData(698103, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_glowingegg + ItemNames.HEALING_POULTICE_X_2: ItemData(698104, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_healingpoultice + ItemNames.HOT_SOUP_X_2: ItemData(698105, 1, ItemType.PROGRESSION, ItemGroup.RECIPE), # ingredient_hotsoup + ItemNames.LEADERSHIP_ROLL_X_2: ItemData(698106, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_leadershiproll + ItemNames.LEAF_POULTICE_X_3: ItemData(698107, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_leafpoultice + ItemNames.PLANT_LEAF_X_2: ItemData(698108, 2, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_plantleaf + ItemNames.PLANT_LEAF_X_3: ItemData(698109, 4, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_plantleaf + ItemNames.ROTTEN_MEAT_X_2: ItemData(698110, 1, ItemType.JUNK, ItemGroup.INGREDIENT), # ingredient_rottenmeat + ItemNames.ROTTEN_MEAT_X_8: ItemData(698111, 1, ItemType.JUNK, ItemGroup.INGREDIENT), # ingredient_rottenmeat + ItemNames.SEA_LOAF_X_2: ItemData(698112, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_sealoaf + ItemNames.SMALL_BONE_X_3: ItemData(698113, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallbone + ItemNames.SMALL_EGG_X_2: ItemData(698114, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallegg + ItemNames.LI_AND_LI_SONG: ItemData(698115, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_li + ItemNames.SHIELD_SONG: ItemData(698116, 1, ItemType.NORMAL, ItemGroup.SONG), # song_shield + ItemNames.BEAST_FORM: ItemData(698117, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_beast + ItemNames.SUN_FORM: ItemData(698118, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_sun + ItemNames.NATURE_FORM: ItemData(698119, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_nature + ItemNames.ENERGY_FORM: ItemData(698120, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_energy + ItemNames.BIND_SONG: ItemData(698121, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_bind + ItemNames.FISH_FORM: ItemData(698122, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_fish + ItemNames.SPIRIT_FORM: ItemData(698123, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_spirit + ItemNames.DUAL_FORM: ItemData(698124, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_dual + ItemNames.TRANSTURTLE_VEIL_TOP_LEFT: ItemData(698125, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_veil01 + ItemNames.TRANSTURTLE_VEIL_TOP_RIGHT: ItemData(698126, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_veil02 + ItemNames.TRANSTURTLE_OPEN_WATERS: ItemData(698127, 1, ItemType.PROGRESSION, + ItemGroup.TURTLE), # transport_openwater03 + ItemNames.TRANSTURTLE_KELP_FOREST: ItemData(698128, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), + # transport_forest04 + ItemNames.TRANSTURTLE_HOME_WATERS: ItemData(698129, 1, ItemType.NORMAL, ItemGroup.TURTLE), # transport_mainarea + ItemNames.TRANSTURTLE_ABYSS: ItemData(698130, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_abyss03 + ItemNames.TRANSTURTLE_BODY: ItemData(698131, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_finalboss + ItemNames.TRANSTURTLE_SIMON_SAYS: ItemData(698132, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_forest05 + ItemNames.TRANSTURTLE_ARNASSI_RUINS: ItemData(698133, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_seahorse } diff --git a/worlds/aquaria/Locations.py b/worlds/aquaria/Locations.py index f6e098103fdc..832d22f4ac87 100644 --- a/worlds/aquaria/Locations.py +++ b/worlds/aquaria/Locations.py @@ -26,476 +26,785 @@ def __init__(self, player: int, name="", code=None, parent=None) -> None: self.event = code is None -class AquariaLocations: +class AquariaLocationNames: + """ + Constants used to represent every name of every locations. + """ + + VERSE_CAVE_RIGHT_AREA_BULB_IN_THE_SKELETON_ROOM = "Verse Cave right area, bulb in the skeleton room" + VERSE_CAVE_RIGHT_AREA_BULB_IN_THE_PATH_RIGHT_OF_THE_SKELETON_ROOM = \ + "Verse Cave right area, bulb in the path right of the skeleton room" + VERSE_CAVE_RIGHT_AREA_BIG_SEED = "Verse Cave right area, Big Seed" + VERSE_CAVE_LEFT_AREA_THE_NAIJA_HINT_ABOUT_THE_SHIELD_ABILITY = \ + "Verse Cave left area, the Naija hint about the shield ability" + VERSE_CAVE_LEFT_AREA_BULB_IN_THE_CENTER_PART = "Verse Cave left area, bulb in the center part" + VERSE_CAVE_LEFT_AREA_BULB_IN_THE_RIGHT_PART = "Verse Cave left area, bulb in the right part" + VERSE_CAVE_LEFT_AREA_BULB_UNDER_THE_ROCK_AT_THE_END_OF_THE_PATH = \ + "Verse Cave left area, bulb under the rock at the end of the path" + HOME_WATERS_BULB_BELOW_THE_GROUPER_FISH = "Home Waters, bulb below the grouper fish" + HOME_WATERS_BULB_IN_THE_LITTLE_ROOM_ABOVE_THE_GROUPER_FISH = \ + "Home Waters, bulb in the little room above the grouper fish" + HOME_WATERS_BULB_IN_THE_END_OF_THE_PATH_CLOSE_TO_THE_VERSE_CAVE = \ + "Home Waters, bulb in the end of the path close to the Verse Cave" + HOME_WATERS_BULB_IN_THE_TOP_LEFT_PATH = "Home Waters, bulb in the top left path" + HOME_WATERS_BULB_CLOSE_TO_NAIJA_S_HOME = "Home Waters, bulb close to Naija's Home" + HOME_WATERS_BULB_UNDER_THE_ROCK_IN_THE_LEFT_PATH_FROM_THE_VERSE_CAVE = \ + "Home Waters, bulb under the rock in the left path from the Verse Cave" + HOME_WATERS_BULB_IN_THE_PATH_BELOW_NAUTILUS_PRIME = "Home Waters, bulb in the path below Nautilus Prime" + HOME_WATERS_BULB_IN_THE_BOTTOM_LEFT_ROOM = "Home Waters, bulb in the bottom left room" + HOME_WATERS_NAUTILUS_EGG = "Home Waters, Nautilus Egg" + HOME_WATERS_TRANSTURTLE = "Home Waters, Transturtle" + NAIJA_S_HOME_BULB_AFTER_THE_ENERGY_DOOR = "Naija's Home, bulb after the energy door" + NAIJA_S_HOME_BULB_UNDER_THE_ROCK_AT_THE_RIGHT_OF_THE_MAIN_PATH = \ + "Naija's Home, bulb under the rock at the right of the main path" + SONG_CAVE_ERULIAN_SPIRIT = "Song Cave, Erulian spirit" + SONG_CAVE_BULB_IN_THE_TOP_RIGHT_PART = "Song Cave, bulb in the top right part" + SONG_CAVE_BULB_IN_THE_BIG_ANEMONE_ROOM = "Song Cave, bulb in the big anemone room" + SONG_CAVE_BULB_IN_THE_PATH_TO_THE_SINGING_STATUES = "Song Cave, bulb in the path to the singing statues" + SONG_CAVE_BULB_UNDER_THE_ROCK_IN_THE_PATH_TO_THE_SINGING_STATUES = \ + "Song Cave, bulb under the rock in the path to the singing statues" + SONG_CAVE_BULB_UNDER_THE_ROCK_CLOSE_TO_THE_SONG_DOOR = "Song Cave, bulb under the rock close to the song door" + SONG_CAVE_VERSE_EGG = "Song Cave, Verse Egg" + SONG_CAVE_JELLY_BEACON = "Song Cave, Jelly Beacon" + SONG_CAVE_ANEMONE_SEED = "Song Cave, Anemone Seed" + ENERGY_TEMPLE_FIRST_AREA_BEATING_THE_ENERGY_STATUE = "Energy Temple first area, beating the Energy Statue" + ENERGY_TEMPLE_FIRST_AREA_BULB_IN_THE_BOTTOM_ROOM_BLOCKED_BY_A_ROCK =\ + "Energy Temple first area, bulb in the bottom room blocked by a rock" + ENERGY_TEMPLE_ENERGY_IDOL = "Energy Temple, Energy Idol" + ENERGY_TEMPLE_SECOND_AREA_BULB_UNDER_THE_ROCK = "Energy Temple second area, bulb under the rock" + ENERGY_TEMPLE_BOTTOM_ENTRANCE_KROTITE_ARMOR = "Energy Temple bottom entrance, Krotite Armor" + ENERGY_TEMPLE_THIRD_AREA_BULB_IN_THE_BOTTOM_PATH = "Energy Temple third area, bulb in the bottom path" + ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH = "Energy Temple boss area, Fallen God Tooth" + ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG = "Energy Temple blaster room, Blaster Egg" + OPEN_WATERS_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH = \ + "Open Waters top left area, bulb under the rock in the right path" + OPEN_WATERS_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_LEFT_PATH = \ + "Open Waters top left area, bulb under the rock in the left path" + OPEN_WATERS_TOP_LEFT_AREA_BULB_TO_THE_RIGHT_OF_THE_SAVE_CRYSTAL = \ + "Open Waters top left area, bulb to the right of the save crystal" + OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_SMALL_PATH_BEFORE_MITHALAS = \ + "Open Waters top right area, bulb in the small path before Mithalas" + OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_PATH_FROM_THE_LEFT_ENTRANCE = \ + "Open Waters top right area, bulb in the path from the left entrance" + OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_CLEARING_CLOSE_TO_THE_BOTTOM_EXIT = \ + "Open Waters top right area, bulb in the clearing close to the bottom exit" + OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_BIG_CLEARING_CLOSE_TO_THE_SAVE_CRYSTAL = \ + "Open Waters top right area, bulb in the big clearing close to the save crystal" + OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_BIG_CLEARING_TO_THE_TOP_EXIT = \ + "Open Waters top right area, bulb in the big clearing to the top exit" + OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_TURTLE_ROOM = "Open Waters top right area, bulb in the turtle room" + OPEN_WATERS_TOP_RIGHT_AREA_TRANSTURTLE = "Open Waters top right area, Transturtle" + OPEN_WATERS_TOP_RIGHT_AREA_FIRST_URN_IN_THE_MITHALAS_EXIT = \ + "Open Waters top right area, first urn in the Mithalas exit" + OPEN_WATERS_TOP_RIGHT_AREA_SECOND_URN_IN_THE_MITHALAS_EXIT = \ + "Open Waters top right area, second urn in the Mithalas exit" + OPEN_WATERS_TOP_RIGHT_AREA_THIRD_URN_IN_THE_MITHALAS_EXIT = \ + "Open Waters top right area, third urn in the Mithalas exit" + OPEN_WATERS_BOTTOM_LEFT_AREA_BULB_BEHIND_THE_CHOMPER_FISH = \ + "Open Waters bottom left area, bulb behind the chomper fish" + OPEN_WATERS_BOTTOM_LEFT_AREA_BULB_INSIDE_THE_LOWEST_FISH_PASS = \ + "Open Waters bottom left area, bulb inside the lowest fish pass" + OPEN_WATERS_SKELETON_PATH_BULB_CLOSE_TO_THE_RIGHT_EXIT = "Open Waters skeleton path, bulb close to the right exit" + OPEN_WATERS_SKELETON_PATH_BULB_BEHIND_THE_CHOMPER_FISH = "Open Waters skeleton path, bulb behind the chomper fish" + OPEN_WATERS_SKELETON_PATH_KING_SKULL = "Open Waters skeleton path, King Skull" + ARNASSI_RUINS_BULB_IN_THE_RIGHT_PART = "Arnassi Ruins, bulb in the right part" + ARNASSI_RUINS_BULB_IN_THE_LEFT_PART = "Arnassi Ruins, bulb in the left part" + ARNASSI_RUINS_BULB_IN_THE_CENTER_PART = "Arnassi Ruins, bulb in the center part" + ARNASSI_RUINS_SONG_PLANT_SPORE = "Arnassi Ruins, Song Plant Spore" + ARNASSI_RUINS_ARNASSI_ARMOR = "Arnassi Ruins, Arnassi Armor" + ARNASSI_RUINS_ARNASSI_STATUE = "Arnassi Ruins, Arnassi Statue" + ARNASSI_RUINS_TRANSTURTLE = "Arnassi Ruins, Transturtle" + ARNASSI_RUINS_CRAB_ARMOR = "Arnassi Ruins, Crab Armor" + SIMON_SAYS_AREA_BEATING_SIMON_SAYS = "Simon Says area, beating Simon Says" + SIMON_SAYS_AREA_TRANSTURTLE = "Simon Says area, Transturtle" + MITHALAS_CITY_FIRST_BULB_IN_THE_LEFT_CITY_PART = "Mithalas City, first bulb in the left city part" + MITHALAS_CITY_SECOND_BULB_IN_THE_LEFT_CITY_PART = "Mithalas City, second bulb in the left city part" + MITHALAS_CITY_BULB_IN_THE_RIGHT_PART = "Mithalas City, bulb in the right part" + MITHALAS_CITY_BULB_AT_THE_TOP_OF_THE_CITY = "Mithalas City, bulb at the top of the city" + MITHALAS_CITY_FIRST_BULB_IN_A_BROKEN_HOME = "Mithalas City, first bulb in a broken home" + MITHALAS_CITY_SECOND_BULB_IN_A_BROKEN_HOME = "Mithalas City, second bulb in a broken home" + MITHALAS_CITY_BULB_IN_THE_BOTTOM_LEFT_PART = "Mithalas City, bulb in the bottom left part" + MITHALAS_CITY_FIRST_BULB_IN_ONE_OF_THE_HOMES = "Mithalas City, first bulb in one of the homes" + MITHALAS_CITY_SECOND_BULB_IN_ONE_OF_THE_HOMES = "Mithalas City, second bulb in one of the homes" + MITHALAS_CITY_FIRST_URN_IN_ONE_OF_THE_HOMES = "Mithalas City, first urn in one of the homes" + MITHALAS_CITY_SECOND_URN_IN_ONE_OF_THE_HOMES = "Mithalas City, second urn in one of the homes" + MITHALAS_CITY_FIRST_URN_IN_THE_CITY_RESERVE = "Mithalas City, first urn in the city reserve" + MITHALAS_CITY_SECOND_URN_IN_THE_CITY_RESERVE = "Mithalas City, second urn in the city reserve" + MITHALAS_CITY_THIRD_URN_IN_THE_CITY_RESERVE = "Mithalas City, third urn in the city reserve" + MITHALAS_CITY_FIRST_BULB_AT_THE_END_OF_THE_TOP_PATH = "Mithalas City, first bulb at the end of the top path" + MITHALAS_CITY_SECOND_BULB_AT_THE_END_OF_THE_TOP_PATH = "Mithalas City, second bulb at the end of the top path" + MITHALAS_CITY_BULB_IN_THE_TOP_PATH = "Mithalas City, bulb in the top path" + MITHALAS_CITY_MITHALAS_POT = "Mithalas City, Mithalas Pot" + MITHALAS_CITY_URN_IN_THE_CASTLE_FLOWER_TUBE_ENTRANCE = "Mithalas City, urn in the Castle flower tube entrance" + MITHALAS_CITY_DOLL = "Mithalas City, Doll" + MITHALAS_CITY_URN_INSIDE_A_HOME_FISH_PASS = "Mithalas City, urn inside a home fish pass" + MITHALAS_CITY_CASTLE_BULB_IN_THE_FLESH_HOLE = "Mithalas City Castle, bulb in the flesh hole" + MITHALAS_CITY_CASTLE_BLUE_BANNER = "Mithalas City Castle, Blue Banner" + MITHALAS_CITY_CASTLE_URN_IN_THE_BEDROOM = "Mithalas City Castle, urn in the bedroom" + MITHALAS_CITY_CASTLE_FIRST_URN_OF_THE_SINGLE_LAMP_PATH = "Mithalas City Castle, first urn of the single lamp path" + MITHALAS_CITY_CASTLE_SECOND_URN_OF_THE_SINGLE_LAMP_PATH = "Mithalas City Castle, second urn of the single lamp path" + MITHALAS_CITY_CASTLE_URN_IN_THE_BOTTOM_ROOM = "Mithalas City Castle, urn in the bottom room" + MITHALAS_CITY_CASTLE_FIRST_URN_ON_THE_ENTRANCE_PATH = "Mithalas City Castle, first urn on the entrance path" + MITHALAS_CITY_CASTLE_SECOND_URN_ON_THE_ENTRANCE_PATH = "Mithalas City Castle, second urn on the entrance path" + MITHALAS_CITY_CASTLE_BEATING_THE_PRIESTS = "Mithalas City Castle, beating the Priests" + MITHALAS_CITY_CASTLE_TRIDENT_HEAD = "Mithalas City Castle, Trident Head" + MITHALAS_CATHEDRAL_BULB_IN_THE_FLESH_ROOM_WITH_FLEAS = "Mithalas Cathedral, bulb in the flesh room with fleas" + MITHALAS_CATHEDRAL_MITHALAN_DRESS = "Mithalas Cathedral, Mithalan Dress" + MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_TOP_RIGHT_ROOM = "Mithalas Cathedral, first urn in the top right room" + MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_TOP_RIGHT_ROOM = "Mithalas Cathedral, second urn in the top right room" + MITHALAS_CATHEDRAL_THIRD_URN_IN_THE_TOP_RIGHT_ROOM = "Mithalas Cathedral, third urn in the top right room" + MITHALAS_CATHEDRAL_URN_BEHIND_THE_FLESH_VEIN = "Mithalas Cathedral, urn behind the flesh vein" + MITHALAS_CATHEDRAL_URN_IN_THE_TOP_LEFT_EYES_BOSS_ROOM = "Mithalas Cathedral, urn in the top left eyes boss room" + MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN =\ + "Mithalas Cathedral, first urn in the path behind the flesh vein" + MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN =\ + "Mithalas Cathedral, second urn in the path behind the flesh vein" + MITHALAS_CATHEDRAL_THIRD_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN =\ + "Mithalas Cathedral, third urn in the path behind the flesh vein" + MITHALAS_CATHEDRAL_FOURTH_URN_IN_THE_TOP_RIGHT_ROOM = "Mithalas Cathedral, fourth urn in the top right room" + MITHALAS_CATHEDRAL_URN_BELOW_THE_LEFT_ENTRANCE = "Mithalas Cathedral, urn below the left entrance" + MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_BOTTOM_RIGHT_PATH = "Mithalas Cathedral, first urn in the bottom right path" + MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_BOTTOM_RIGHT_PATH = "Mithalas Cathedral, second urn in the bottom right path" + CATHEDRAL_UNDERGROUND_BULB_IN_THE_CENTER_PART = "Cathedral Underground, bulb in the center part" + CATHEDRAL_UNDERGROUND_FIRST_BULB_IN_THE_TOP_LEFT_PART = "Cathedral Underground, first bulb in the top left part" + CATHEDRAL_UNDERGROUND_SECOND_BULB_IN_THE_TOP_LEFT_PART = "Cathedral Underground, second bulb in the top left part" + CATHEDRAL_UNDERGROUND_THIRD_BULB_IN_THE_TOP_LEFT_PART = "Cathedral Underground, third bulb in the top left part" + CATHEDRAL_UNDERGROUND_BULB_CLOSE_TO_THE_SAVE_CRYSTAL = "Cathedral Underground, bulb close to the save crystal" + CATHEDRAL_UNDERGROUND_BULB_IN_THE_BOTTOM_RIGHT_PATH = "Cathedral Underground, bulb in the bottom right path" + MITHALAS_BOSS_AREA_BEATING_MITHALAN_GOD = "Mithalas boss area, beating Mithalan God" + KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_BOTTOM_LEFT_CLEARING =\ + "Kelp Forest top left area, bulb in the bottom left clearing" + KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_PATH_DOWN_FROM_THE_TOP_LEFT_CLEARING =\ + "Kelp Forest top left area, bulb in the path down from the top left clearing" + KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_TOP_LEFT_CLEARING = "Kelp Forest top left area, bulb in the top left clearing" + KELP_FOREST_TOP_LEFT_AREA_JELLY_EGG = "Kelp Forest top left area, Jelly Egg" + KELP_FOREST_TOP_LEFT_AREA_BULB_CLOSE_TO_THE_VERSE_EGG = "Kelp Forest top left area, bulb close to the Verse Egg" + KELP_FOREST_TOP_LEFT_AREA_VERSE_EGG = "Kelp Forest top left area, Verse Egg" + KELP_FOREST_TOP_RIGHT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH =\ + "Kelp Forest top right area, bulb under the rock in the right path" + KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_LEFT_OF_THE_CENTER_CLEARING =\ + "Kelp Forest top right area, bulb at the left of the center clearing" + KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_BIG_ROOM =\ + "Kelp Forest top right area, bulb in the left path's big room" + KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_SMALL_ROOM =\ + "Kelp Forest top right area, bulb in the left path's small room" + KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_CENTER_CLEARING =\ + "Kelp Forest top right area, bulb at the top of the center clearing" + KELP_FOREST_TOP_RIGHT_AREA_BLACK_PEARL = "Kelp Forest top right area, Black Pearl" + KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_TOP_FISH_PASS = "Kelp Forest top right area, bulb in the top fish pass" + KELP_FOREST_BOTTOM_LEFT_AREA_TRANSTURTLE = "Kelp Forest bottom left area, Transturtle" + KELP_FOREST_BOTTOM_LEFT_AREA_BULB_CLOSE_TO_THE_SPIRIT_CRYSTALS =\ + "Kelp Forest bottom left area, bulb close to the spirit crystals" + KELP_FOREST_BOTTOM_LEFT_AREA_WALKER_BABY = "Kelp Forest bottom left area, Walker Baby" + KELP_FOREST_BOTTOM_LEFT_AREA_FISH_CAVE_PUZZLE = "Kelp Forest bottom left area, Fish Cave puzzle" + KELP_FOREST_BOTTOM_RIGHT_AREA_ODD_CONTAINER = "Kelp Forest bottom right area, Odd Container" + KELP_FOREST_BOSS_AREA_BEATING_DRUNIAN_GOD = "Kelp Forest boss area, beating Drunian God" + KELP_FOREST_BOSS_ROOM_BULB_AT_THE_BOTTOM_OF_THE_AREA = "Kelp Forest boss room, bulb at the bottom of the area" + KELP_FOREST_SPRITE_CAVE_BULB_INSIDE_THE_FISH_PASS = "Kelp Forest sprite cave, bulb inside the fish pass" + KELP_FOREST_SPRITE_CAVE_BULB_IN_THE_SECOND_ROOM = "Kelp Forest sprite cave, bulb in the second room" + KELP_FOREST_SPRITE_CAVE_SEED_BAG = "Kelp Forest sprite cave, Seed Bag" + MERMOG_CAVE_BULB_IN_THE_LEFT_PART_OF_THE_CAVE = "Mermog cave, bulb in the left part of the cave" + MERMOG_CAVE_PIRANHA_EGG = "Mermog cave, Piranha Egg" + THE_VEIL_TOP_LEFT_AREA_IN_LI_S_CAVE = "The Veil top left area, In Li's cave" + THE_VEIL_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_TOP_RIGHT_PATH =\ + "The Veil top left area, bulb under the rock in the top right path" + THE_VEIL_TOP_LEFT_AREA_BULB_HIDDEN_BEHIND_THE_BLOCKING_ROCK =\ + "The Veil top left area, bulb hidden behind the blocking rock" + THE_VEIL_TOP_LEFT_AREA_TRANSTURTLE = "The Veil top left area, Transturtle" + THE_VEIL_TOP_LEFT_AREA_BULB_INSIDE_THE_FISH_PASS = "The Veil top left area, bulb inside the fish pass" + TURTLE_CAVE_TURTLE_EGG = "Turtle cave, Turtle Egg" + TURTLE_CAVE_BULB_IN_BUBBLE_CLIFF = "Turtle cave, bulb in Bubble Cliff" + TURTLE_CAVE_URCHIN_COSTUME = "Turtle cave, Urchin Costume" + THE_VEIL_TOP_RIGHT_AREA_BULB_IN_THE_MIDDLE_OF_THE_WALL_JUMP_CLIFF = \ + "The Veil top right area, bulb in the middle of the wall jump cliff" + THE_VEIL_TOP_RIGHT_AREA_GOLDEN_STARFISH = "The Veil top right area, Golden Starfish" + THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL = \ + "The Veil top right area, bulb at the top of the waterfall" + THE_VEIL_TOP_RIGHT_AREA_TRANSTURTLE = "The Veil top right area, Transturtle" + THE_VEIL_BOTTOM_AREA_BULB_IN_THE_LEFT_PATH = "The Veil bottom area, bulb in the left path" + THE_VEIL_BOTTOM_AREA_BULB_IN_THE_SPIRIT_PATH = "The Veil bottom area, bulb in the spirit path" + THE_VEIL_BOTTOM_AREA_VERSE_EGG = "The Veil bottom area, Verse Egg" + THE_VEIL_BOTTOM_AREA_STONE_HEAD = "The Veil bottom area, Stone Head" + OCTOPUS_CAVE_DUMBO_EGG = "Octopus Cave, Dumbo Egg" + OCTOPUS_CAVE_BULB_IN_THE_PATH_BELOW_THE_OCTOPUS_CAVE_PATH =\ + "Octopus Cave, bulb in the path below the Octopus Cave path" + SUN_TEMPLE_BULB_IN_THE_TOP_LEFT_PART = "Sun Temple, bulb in the top left part" + SUN_TEMPLE_BULB_IN_THE_TOP_RIGHT_PART = "Sun Temple, bulb in the top right part" + SUN_TEMPLE_BULB_AT_THE_TOP_OF_THE_HIGH_DARK_ROOM = "Sun Temple, bulb at the top of the high dark room" + SUN_TEMPLE_GOLDEN_GEAR = "Sun Temple, Golden Gear" + SUN_TEMPLE_FIRST_BULB_OF_THE_TEMPLE = "Sun Temple, first bulb of the temple" + SUN_TEMPLE_BULB_ON_THE_RIGHT_PART = "Sun Temple, bulb on the right part" + SUN_TEMPLE_BULB_IN_THE_HIDDEN_ROOM_OF_THE_RIGHT_PART = "Sun Temple, bulb in the hidden room of the right part" + SUN_TEMPLE_SUN_KEY = "Sun Temple, Sun Key" + SUN_TEMPLE_BOSS_PATH_FIRST_PATH_BULB = "Sun Temple boss path, first path bulb" + SUN_TEMPLE_BOSS_PATH_SECOND_PATH_BULB = "Sun Temple boss path, second path bulb" + SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB = "Sun Temple boss path, first cliff bulb" + SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB = "Sun Temple boss path, second cliff bulb" + SUN_TEMPLE_BOSS_AREA_BEATING_LUMEREAN_GOD = "Sun Temple boss area, beating Lumerean God" + ABYSS_LEFT_AREA_BULB_IN_HIDDEN_PATH_ROOM = "Abyss left area, bulb in hidden path room" + ABYSS_LEFT_AREA_BULB_IN_THE_RIGHT_PART = "Abyss left area, bulb in the right part" + ABYSS_LEFT_AREA_GLOWING_SEED = "Abyss left area, Glowing Seed" + ABYSS_LEFT_AREA_GLOWING_PLANT = "Abyss left area, Glowing Plant" + ABYSS_LEFT_AREA_BULB_IN_THE_BOTTOM_FISH_PASS = "Abyss left area, bulb in the bottom fish pass" + ABYSS_RIGHT_AREA_BULB_IN_THE_MIDDLE_PATH = "Abyss right area, bulb in the middle path" + ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_MIDDLE_PATH =\ + "Abyss right area, bulb behind the rock in the middle path" + ABYSS_RIGHT_AREA_BULB_IN_THE_LEFT_GREEN_ROOM = "Abyss right area, bulb in the left green room" + ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_WHALE_ROOM = "Abyss right area, bulb behind the rock in the whale room" + ABYSS_RIGHT_AREA_TRANSTURTLE = "Abyss right area, Transturtle" + ICE_CAVERN_BULB_IN_THE_ROOM_TO_THE_RIGHT = "Ice Cavern, bulb in the room to the right" + ICE_CAVERN_FIRST_BULB_IN_THE_TOP_EXIT_ROOM = "Ice Cavern, first bulb in the top exit room" + ICE_CAVERN_SECOND_BULB_IN_THE_TOP_EXIT_ROOM = "Ice Cavern, second bulb in the top exit room" + ICE_CAVERN_THIRD_BULB_IN_THE_TOP_EXIT_ROOM = "Ice Cavern, third bulb in the top exit room" + ICE_CAVERN_BULB_IN_THE_LEFT_ROOM = "Ice Cavern, bulb in the left room" + BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL = "Bubble Cave, bulb in the left cave wall" + BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL =\ + "Bubble Cave, bulb in the right cave wall (behind the ice crystal)" + BUBBLE_CAVE_VERSE_EGG = "Bubble Cave, Verse Egg" + KING_JELLYFISH_CAVE_BULB_IN_THE_RIGHT_PATH_FROM_KING_JELLY =\ + "King Jellyfish Cave, bulb in the right path from King Jelly" + KING_JELLYFISH_CAVE_JELLYFISH_COSTUME = "King Jellyfish Cave, Jellyfish Costume" + THE_WHALE_VERSE_EGG = "The Whale, Verse Egg" + SUNKEN_CITY_RIGHT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL = "Sunken City right area, crate close to the save crystal" + SUNKEN_CITY_RIGHT_AREA_CRATE_IN_THE_LEFT_BOTTOM_ROOM = "Sunken City right area, crate in the left bottom room" + SUNKEN_CITY_LEFT_AREA_CRATE_IN_THE_LITTLE_PIPE_ROOM = "Sunken City left area, crate in the little pipe room" + SUNKEN_CITY_LEFT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL = "Sunken City left area, crate close to the save crystal" + SUNKEN_CITY_LEFT_AREA_CRATE_BEFORE_THE_BEDROOM = "Sunken City left area, crate before the bedroom" + SUNKEN_CITY_LEFT_AREA_GIRL_COSTUME = "Sunken City left area, Girl Costume" + SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA = "Sunken City, bulb on top of the boss area" + THE_BODY_CENTER_AREA_BREAKING_LI_S_CAGE = "The Body center area, breaking Li's cage" + THE_BODY_CENTER_AREA_BULB_ON_THE_MAIN_PATH_BLOCKING_TUBE = \ + "The Body center area, bulb on the main path blocking tube" + THE_BODY_LEFT_AREA_FIRST_BULB_IN_THE_TOP_FACE_ROOM = "The Body left area, first bulb in the top face room" + THE_BODY_LEFT_AREA_SECOND_BULB_IN_THE_TOP_FACE_ROOM = "The Body left area, second bulb in the top face room" + THE_BODY_LEFT_AREA_BULB_BELOW_THE_WATER_STREAM = "The Body left area, bulb below the water stream" + THE_BODY_LEFT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_TOP_FACE_ROOM = \ + "The Body left area, bulb in the top path to the top face room" + THE_BODY_LEFT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM = "The Body left area, bulb in the bottom face room" + THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_FACE_ROOM = "The Body right area, bulb in the top face room" + THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_BOTTOM_FACE_ROOM = \ + "The Body right area, bulb in the top path to the bottom face room" + THE_BODY_RIGHT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM = "The Body right area, bulb in the bottom face room" + THE_BODY_BOTTOM_AREA_BULB_IN_THE_JELLY_ZAP_ROOM = "The Body bottom area, bulb in the Jelly Zap room" + THE_BODY_BOTTOM_AREA_BULB_IN_THE_NAUTILUS_ROOM = "The Body bottom area, bulb in the nautilus room" + THE_BODY_BOTTOM_AREA_MUTANT_COSTUME = "The Body bottom area, Mutant Costume" + FINAL_BOSS_AREA_FIRST_BULB_IN_THE_TURTLE_ROOM = "Final Boss area, first bulb in the turtle room" + FINAL_BOSS_AREA_SECOND_BULB_IN_THE_TURTLE_ROOM = "Final Boss area, second bulb in the turtle room" + FINAL_BOSS_AREA_THIRD_BULB_IN_THE_TURTLE_ROOM = "Final Boss area, third bulb in the turtle room" + FINAL_BOSS_AREA_TRANSTURTLE = "Final Boss area, Transturtle" + FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM = "Final Boss area, bulb in the boss third form room" + BEATING_FALLEN_GOD = "Beating Fallen God" + BEATING_MITHALAN_GOD = "Beating Mithalan God" + BEATING_DRUNIAN_GOD = "Beating Drunian God" + BEATING_LUMEREAN_GOD = "Beating Lumerean God" + BEATING_THE_GOLEM = "Beating the Golem" + BEATING_NAUTILUS_PRIME = "Beating Nautilus Prime" + BEATING_BLASTER_PEG_PRIME = "Beating Blaster Peg Prime" + BEATING_MERGOG = "Beating Mergog" + BEATING_MITHALAN_PRIESTS = "Beating Mithalan priests" + BEATING_OCTOPUS_PRIME = "Beating Octopus Prime" + BEATING_CRABBIUS_MAXIMUS = "Beating Crabbius Maximus" + BEATING_MANTIS_SHRIMP_PRIME = "Beating Mantis Shrimp Prime" + BEATING_KING_JELLYFISH_GOD_PRIME = "Beating King Jellyfish God Prime" + FIRST_SECRET = "First Secret" + SECOND_SECRET = "Second Secret" + THIRD_SECRET = "Third Secret" + SUNKEN_CITY_CLEARED = "Sunken City cleared" + SUN_CRYSTAL = "Sun Crystal" + OBJECTIVE_COMPLETE = "Objective complete" + +class AquariaLocations: locations_verse_cave_r = { - "Verse Cave, bulb in the skeleton room": 698107, - "Verse Cave, bulb in the path right of the skeleton room": 698108, - "Verse Cave right area, Big Seed": 698175, + AquariaLocationNames.VERSE_CAVE_RIGHT_AREA_BULB_IN_THE_SKELETON_ROOM: 698107, + AquariaLocationNames.VERSE_CAVE_RIGHT_AREA_BULB_IN_THE_PATH_RIGHT_OF_THE_SKELETON_ROOM: 698108, + AquariaLocationNames.VERSE_CAVE_RIGHT_AREA_BIG_SEED: 698175, } locations_verse_cave_l = { - "Verse Cave, the Naija hint about the shield ability": 698200, - "Verse Cave left area, bulb in the center part": 698021, - "Verse Cave left area, bulb in the right part": 698022, - "Verse Cave left area, bulb under the rock at the end of the path": 698023, + AquariaLocationNames.VERSE_CAVE_LEFT_AREA_THE_NAIJA_HINT_ABOUT_THE_SHIELD_ABILITY: 698200, + AquariaLocationNames.VERSE_CAVE_LEFT_AREA_BULB_IN_THE_CENTER_PART: 698021, + AquariaLocationNames.VERSE_CAVE_LEFT_AREA_BULB_IN_THE_RIGHT_PART: 698022, + AquariaLocationNames.VERSE_CAVE_LEFT_AREA_BULB_UNDER_THE_ROCK_AT_THE_END_OF_THE_PATH: 698023, } locations_home_water = { - "Home Water, bulb below the grouper fish": 698058, - "Home Water, bulb in the path below Nautilus Prime": 698059, - "Home Water, bulb in the little room above the grouper fish": 698060, - "Home Water, bulb in the end of the path close to the Verse Cave": 698061, - "Home Water, bulb in the top left path": 698062, - "Home Water, bulb in the bottom left room": 698063, - "Home Water, bulb close to Naija's Home": 698064, - "Home Water, bulb under the rock in the left path from the Verse Cave": 698065, + AquariaLocationNames.HOME_WATERS_BULB_BELOW_THE_GROUPER_FISH: 698058, + AquariaLocationNames.HOME_WATERS_BULB_IN_THE_LITTLE_ROOM_ABOVE_THE_GROUPER_FISH: 698060, + AquariaLocationNames.HOME_WATERS_BULB_IN_THE_END_OF_THE_PATH_CLOSE_TO_THE_VERSE_CAVE: 698061, + AquariaLocationNames.HOME_WATERS_BULB_IN_THE_TOP_LEFT_PATH: 698062, + AquariaLocationNames.HOME_WATERS_BULB_CLOSE_TO_NAIJA_S_HOME: 698064, + AquariaLocationNames.HOME_WATERS_BULB_UNDER_THE_ROCK_IN_THE_LEFT_PATH_FROM_THE_VERSE_CAVE: 698065, + } + + locations_home_water_behind_rocks = { + AquariaLocationNames.HOME_WATERS_BULB_IN_THE_PATH_BELOW_NAUTILUS_PRIME: 698059, + AquariaLocationNames.HOME_WATERS_BULB_IN_THE_BOTTOM_LEFT_ROOM: 698063, } locations_home_water_nautilus = { - "Home Water, Nautilus Egg": 698194, + AquariaLocationNames.HOME_WATERS_NAUTILUS_EGG: 698194, } locations_home_water_transturtle = { - "Home Water, Transturtle": 698213, + AquariaLocationNames.HOME_WATERS_TRANSTURTLE: 698213, } locations_naija_home = { - "Naija's Home, bulb after the energy door": 698119, - "Naija's Home, bulb under the rock at the right of the main path": 698120, + AquariaLocationNames.NAIJA_S_HOME_BULB_AFTER_THE_ENERGY_DOOR: 698119, + AquariaLocationNames.NAIJA_S_HOME_BULB_UNDER_THE_ROCK_AT_THE_RIGHT_OF_THE_MAIN_PATH: 698120, } locations_song_cave = { - "Song Cave, Erulian spirit": 698206, - "Song Cave, bulb in the top right part": 698071, - "Song Cave, bulb in the big anemone room": 698072, - "Song Cave, bulb in the path to the singing statues": 698073, - "Song Cave, bulb under the rock in the path to the singing statues": 698074, - "Song Cave, bulb under the rock close to the song door": 698075, - "Song Cave, Verse Egg": 698160, - "Song Cave, Jelly Beacon": 698178, - "Song Cave, Anemone Seed": 698162, + AquariaLocationNames.SONG_CAVE_ERULIAN_SPIRIT: 698206, + AquariaLocationNames.SONG_CAVE_BULB_IN_THE_TOP_RIGHT_PART: 698071, + AquariaLocationNames.SONG_CAVE_BULB_IN_THE_BIG_ANEMONE_ROOM: 698072, + AquariaLocationNames.SONG_CAVE_BULB_IN_THE_PATH_TO_THE_SINGING_STATUES: 698073, + AquariaLocationNames.SONG_CAVE_BULB_UNDER_THE_ROCK_IN_THE_PATH_TO_THE_SINGING_STATUES: 698074, + AquariaLocationNames.SONG_CAVE_BULB_UNDER_THE_ROCK_CLOSE_TO_THE_SONG_DOOR: 698075, + AquariaLocationNames.SONG_CAVE_VERSE_EGG: 698160, + AquariaLocationNames.SONG_CAVE_JELLY_BEACON: 698178, + AquariaLocationNames.SONG_CAVE_ANEMONE_SEED: 698162, } locations_energy_temple_1 = { - "Energy Temple first area, beating the Energy Statue": 698205, - "Energy Temple first area, bulb in the bottom room blocked by a rock": 698027, + AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BEATING_THE_ENERGY_STATUE: 698205, + AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BULB_IN_THE_BOTTOM_ROOM_BLOCKED_BY_A_ROCK: 698027, } locations_energy_temple_idol = { - "Energy Temple first area, Energy Idol": 698170, + AquariaLocationNames.ENERGY_TEMPLE_ENERGY_IDOL: 698170, } locations_energy_temple_2 = { - "Energy Temple second area, bulb under the rock": 698028, + AquariaLocationNames.ENERGY_TEMPLE_SECOND_AREA_BULB_UNDER_THE_ROCK: 698028, + # This can be accessible via locations_energy_temple_altar too } locations_energy_temple_altar = { - "Energy Temple bottom entrance, Krotite Armor": 698163, + AquariaLocationNames.ENERGY_TEMPLE_BOTTOM_ENTRANCE_KROTITE_ARMOR: 698163, } locations_energy_temple_3 = { - "Energy Temple third area, bulb in the bottom path": 698029, + AquariaLocationNames.ENERGY_TEMPLE_THIRD_AREA_BULB_IN_THE_BOTTOM_PATH: 698029, } locations_energy_temple_boss = { - "Energy Temple boss area, Fallen God Tooth": 698169, + AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH: 698169, } locations_energy_temple_blaster_room = { - "Energy Temple blaster room, Blaster Egg": 698195, + AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG: 698195, } locations_openwater_tl = { - "Open Water top left area, bulb under the rock in the right path": 698001, - "Open Water top left area, bulb under the rock in the left path": 698002, - "Open Water top left area, bulb to the right of the save crystal": 698003, + AquariaLocationNames.OPEN_WATERS_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH: 698001, + AquariaLocationNames.OPEN_WATERS_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_LEFT_PATH: 698002, + AquariaLocationNames.OPEN_WATERS_TOP_LEFT_AREA_BULB_TO_THE_RIGHT_OF_THE_SAVE_CRYSTAL: 698003, } locations_openwater_tr = { - "Open Water top right area, bulb in the small path before Mithalas": 698004, - "Open Water top right area, bulb in the path from the left entrance": 698005, - "Open Water top right area, bulb in the clearing close to the bottom exit": 698006, - "Open Water top right area, bulb in the big clearing close to the save crystal": 698007, - "Open Water top right area, bulb in the big clearing to the top exit": 698008, - "Open Water top right area, first urn in the Mithalas exit": 698148, - "Open Water top right area, second urn in the Mithalas exit": 698149, - "Open Water top right area, third urn in the Mithalas exit": 698150, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_SMALL_PATH_BEFORE_MITHALAS: 698004, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_PATH_FROM_THE_LEFT_ENTRANCE: 698005, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_CLEARING_CLOSE_TO_THE_BOTTOM_EXIT: 698006, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_BIG_CLEARING_CLOSE_TO_THE_SAVE_CRYSTAL: 698007, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_BIG_CLEARING_TO_THE_TOP_EXIT: 698008, } locations_openwater_tr_turtle = { - "Open Water top right area, bulb in the turtle room": 698009, - "Open Water top right area, Transturtle": 698211, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_TURTLE_ROOM: 698009, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_TRANSTURTLE: 698211, + } + + locations_openwater_tr_urns = { + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_FIRST_URN_IN_THE_MITHALAS_EXIT: 698148, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_SECOND_URN_IN_THE_MITHALAS_EXIT: 698149, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_THIRD_URN_IN_THE_MITHALAS_EXIT: 698150, } locations_openwater_bl = { - "Open Water bottom left area, bulb behind the chomper fish": 698011, - "Open Water bottom left area, bulb inside the lowest fish pass": 698010, + AquariaLocationNames.OPEN_WATERS_BOTTOM_LEFT_AREA_BULB_BEHIND_THE_CHOMPER_FISH: 698011, + AquariaLocationNames.OPEN_WATERS_BOTTOM_LEFT_AREA_BULB_INSIDE_THE_LOWEST_FISH_PASS: 698010, } locations_skeleton_path = { - "Open Water skeleton path, bulb close to the right exit": 698012, - "Open Water skeleton path, bulb behind the chomper fish": 698013, + AquariaLocationNames.OPEN_WATERS_SKELETON_PATH_BULB_CLOSE_TO_THE_RIGHT_EXIT: 698012, + AquariaLocationNames.OPEN_WATERS_SKELETON_PATH_BULB_BEHIND_THE_CHOMPER_FISH: 698013, } locations_skeleton_path_sc = { - "Open Water skeleton path, King Skull": 698177, + AquariaLocationNames.OPEN_WATERS_SKELETON_PATH_KING_SKULL: 698177, } locations_arnassi = { - "Arnassi Ruins, bulb in the right part": 698014, - "Arnassi Ruins, bulb in the left part": 698015, - "Arnassi Ruins, bulb in the center part": 698016, - "Arnassi Ruins, Song Plant Spore": 698179, - "Arnassi Ruins, Arnassi Armor": 698191, + AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_RIGHT_PART: 698014, + AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_LEFT_PART: 698015, + AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_CENTER_PART: 698016, + AquariaLocationNames.ARNASSI_RUINS_SONG_PLANT_SPORE: 698179, + AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR: 698191, } - locations_arnassi_path = { - "Arnassi Ruins, Arnassi Statue": 698164, + locations_arnassi_cave = { + AquariaLocationNames.ARNASSI_RUINS_ARNASSI_STATUE: 698164, } locations_arnassi_cave_transturtle = { - "Arnassi Ruins, Transturtle": 698217, + AquariaLocationNames.ARNASSI_RUINS_TRANSTURTLE: 698217, } locations_arnassi_crab_boss = { - "Arnassi Ruins, Crab Armor": 698187, + AquariaLocationNames.ARNASSI_RUINS_CRAB_ARMOR: 698187, } locations_simon = { - "Simon Says area, beating Simon Says": 698156, - "Simon Says area, Transturtle": 698216, + AquariaLocationNames.SIMON_SAYS_AREA_BEATING_SIMON_SAYS: 698156, + AquariaLocationNames.SIMON_SAYS_AREA_TRANSTURTLE: 698216, } locations_mithalas_city = { - "Mithalas City, first bulb in the left city part": 698030, - "Mithalas City, second bulb in the left city part": 698035, - "Mithalas City, bulb in the right part": 698031, - "Mithalas City, bulb at the top of the city": 698033, - "Mithalas City, first bulb in a broken home": 698034, - "Mithalas City, second bulb in a broken home": 698041, - "Mithalas City, bulb in the bottom left part": 698037, - "Mithalas City, first bulb in one of the homes": 698038, - "Mithalas City, second bulb in one of the homes": 698039, - "Mithalas City, first urn in one of the homes": 698123, - "Mithalas City, second urn in one of the homes": 698124, - "Mithalas City, first urn in the city reserve": 698125, - "Mithalas City, second urn in the city reserve": 698126, - "Mithalas City, third urn in the city reserve": 698127, + AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_THE_LEFT_CITY_PART: 698030, + AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_THE_LEFT_CITY_PART: 698035, + AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_RIGHT_PART: 698031, + AquariaLocationNames.MITHALAS_CITY_BULB_AT_THE_TOP_OF_THE_CITY: 698033, + AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_A_BROKEN_HOME: 698034, + AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_A_BROKEN_HOME: 698041, + AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_BOTTOM_LEFT_PART: 698037, + AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_ONE_OF_THE_HOMES: 698038, + AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_ONE_OF_THE_HOMES: 698039, + } + + locations_mithalas_city_urns = { + AquariaLocationNames.MITHALAS_CITY_FIRST_URN_IN_ONE_OF_THE_HOMES: 698123, + AquariaLocationNames.MITHALAS_CITY_SECOND_URN_IN_ONE_OF_THE_HOMES: 698124, + AquariaLocationNames.MITHALAS_CITY_FIRST_URN_IN_THE_CITY_RESERVE: 698125, + AquariaLocationNames.MITHALAS_CITY_SECOND_URN_IN_THE_CITY_RESERVE: 698126, + AquariaLocationNames.MITHALAS_CITY_THIRD_URN_IN_THE_CITY_RESERVE: 698127, } locations_mithalas_city_top_path = { - "Mithalas City, first bulb at the end of the top path": 698032, - "Mithalas City, second bulb at the end of the top path": 698040, - "Mithalas City, bulb in the top path": 698036, - "Mithalas City, Mithalas Pot": 698174, - "Mithalas City, urn in the Castle flower tube entrance": 698128, + AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_AT_THE_END_OF_THE_TOP_PATH: 698032, + AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_AT_THE_END_OF_THE_TOP_PATH: 698040, + AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_TOP_PATH: 698036, + AquariaLocationNames.MITHALAS_CITY_MITHALAS_POT: 698174, + AquariaLocationNames.MITHALAS_CITY_URN_IN_THE_CASTLE_FLOWER_TUBE_ENTRANCE: 698128, } locations_mithalas_city_fishpass = { - "Mithalas City, Doll": 698173, - "Mithalas City, urn inside a home fish pass": 698129, + AquariaLocationNames.MITHALAS_CITY_DOLL: 698173, + AquariaLocationNames.MITHALAS_CITY_URN_INSIDE_A_HOME_FISH_PASS: 698129, + } + + locations_mithalas_castle = { + AquariaLocationNames.MITHALAS_CITY_CASTLE_BULB_IN_THE_FLESH_HOLE: 698042, + AquariaLocationNames.MITHALAS_CITY_CASTLE_BLUE_BANNER: 698165, + } + + locations_mithalas_castle_urns = { + AquariaLocationNames.MITHALAS_CITY_CASTLE_URN_IN_THE_BEDROOM: 698130, + AquariaLocationNames.MITHALAS_CITY_CASTLE_FIRST_URN_OF_THE_SINGLE_LAMP_PATH: 698131, + AquariaLocationNames.MITHALAS_CITY_CASTLE_SECOND_URN_OF_THE_SINGLE_LAMP_PATH: 698132, + AquariaLocationNames.MITHALAS_CITY_CASTLE_URN_IN_THE_BOTTOM_ROOM: 698133, + AquariaLocationNames.MITHALAS_CITY_CASTLE_FIRST_URN_ON_THE_ENTRANCE_PATH: 698134, + AquariaLocationNames.MITHALAS_CITY_CASTLE_SECOND_URN_ON_THE_ENTRANCE_PATH: 698135, } - locations_cathedral_l = { - "Mithalas City Castle, bulb in the flesh hole": 698042, - "Mithalas City Castle, Blue Banner": 698165, - "Mithalas City Castle, urn in the bedroom": 698130, - "Mithalas City Castle, first urn of the single lamp path": 698131, - "Mithalas City Castle, second urn of the single lamp path": 698132, - "Mithalas City Castle, urn in the bottom room": 698133, - "Mithalas City Castle, first urn on the entrance path": 698134, - "Mithalas City Castle, second urn on the entrance path": 698135, + locations_mithalas_castle_tube = { + AquariaLocationNames.MITHALAS_CITY_CASTLE_BEATING_THE_PRIESTS: 698208, } - locations_cathedral_l_tube = { - "Mithalas City Castle, beating the Priests": 698208, + locations_mithalas_castle_sc = { + AquariaLocationNames.MITHALAS_CITY_CASTLE_TRIDENT_HEAD: 698183, } - locations_cathedral_l_sc = { - "Mithalas City Castle, Trident Head": 698183, + locations_cathedral_top_start = { + AquariaLocationNames.MITHALAS_CATHEDRAL_BULB_IN_THE_FLESH_ROOM_WITH_FLEAS: 698139, + AquariaLocationNames.MITHALAS_CATHEDRAL_MITHALAN_DRESS: 698189, } - locations_cathedral_r = { - "Mithalas Cathedral, first urn in the top right room": 698136, - "Mithalas Cathedral, second urn in the top right room": 698137, - "Mithalas Cathedral, third urn in the top right room": 698138, - "Mithalas Cathedral, urn in the flesh room with fleas": 698139, - "Mithalas Cathedral, first urn in the bottom right path": 698140, - "Mithalas Cathedral, second urn in the bottom right path": 698141, - "Mithalas Cathedral, urn behind the flesh vein": 698142, - "Mithalas Cathedral, urn in the top left eyes boss room": 698143, - "Mithalas Cathedral, first urn in the path behind the flesh vein": 698144, - "Mithalas Cathedral, second urn in the path behind the flesh vein": 698145, - "Mithalas Cathedral, third urn in the path behind the flesh vein": 698146, - "Mithalas Cathedral, fourth urn in the top right room": 698147, - "Mithalas Cathedral, Mithalan Dress": 698189, - "Mithalas Cathedral, urn below the left entrance": 698198, + locations_cathedral_top_start_urns = { + AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_TOP_RIGHT_ROOM: 698136, + AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_TOP_RIGHT_ROOM: 698137, + AquariaLocationNames.MITHALAS_CATHEDRAL_THIRD_URN_IN_THE_TOP_RIGHT_ROOM: 698138, + AquariaLocationNames.MITHALAS_CATHEDRAL_URN_BEHIND_THE_FLESH_VEIN: 698142, + AquariaLocationNames.MITHALAS_CATHEDRAL_URN_IN_THE_TOP_LEFT_EYES_BOSS_ROOM: 698143, + AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN: 698144, + AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN: 698145, + AquariaLocationNames.MITHALAS_CATHEDRAL_THIRD_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN: 698146, + AquariaLocationNames.MITHALAS_CATHEDRAL_FOURTH_URN_IN_THE_TOP_RIGHT_ROOM: 698147, + AquariaLocationNames.MITHALAS_CATHEDRAL_URN_BELOW_THE_LEFT_ENTRANCE: 698198, + } + + locations_cathedral_top_end = { + AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_BOTTOM_RIGHT_PATH: 698140, + AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_BOTTOM_RIGHT_PATH: 698141, } locations_cathedral_underground = { - "Cathedral Underground, bulb in the center part": 698113, - "Cathedral Underground, first bulb in the top left part": 698114, - "Cathedral Underground, second bulb in the top left part": 698115, - "Cathedral Underground, third bulb in the top left part": 698116, - "Cathedral Underground, bulb close to the save crystal": 698117, - "Cathedral Underground, bulb in the bottom right path": 698118, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_IN_THE_CENTER_PART: 698113, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_FIRST_BULB_IN_THE_TOP_LEFT_PART: 698114, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_SECOND_BULB_IN_THE_TOP_LEFT_PART: 698115, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_THIRD_BULB_IN_THE_TOP_LEFT_PART: 698116, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_CLOSE_TO_THE_SAVE_CRYSTAL: 698117, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_IN_THE_BOTTOM_RIGHT_PATH: 698118, } locations_cathedral_boss = { - "Mithalas boss area, beating Mithalan God": 698202, + AquariaLocationNames.MITHALAS_BOSS_AREA_BEATING_MITHALAN_GOD: 698202, } locations_forest_tl = { - "Kelp Forest top left area, bulb in the bottom left clearing": 698044, - "Kelp Forest top left area, bulb in the path down from the top left clearing": 698045, - "Kelp Forest top left area, bulb in the top left clearing": 698046, - "Kelp Forest top left area, Jelly Egg": 698185, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_BOTTOM_LEFT_CLEARING: 698044, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_PATH_DOWN_FROM_THE_TOP_LEFT_CLEARING: 698045, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_TOP_LEFT_CLEARING: 698046, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_JELLY_EGG: 698185, } - locations_forest_tl_fp = { - "Kelp Forest top left area, bulb close to the Verse Egg": 698047, - "Kelp Forest top left area, Verse Egg": 698158, + locations_forest_tl_verse_egg_room = { + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_CLOSE_TO_THE_VERSE_EGG: 698047, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_VERSE_EGG: 698158, } locations_forest_tr = { - "Kelp Forest top right area, bulb under the rock in the right path": 698048, - "Kelp Forest top right area, bulb at the left of the center clearing": 698049, - "Kelp Forest top right area, bulb in the left path's big room": 698051, - "Kelp Forest top right area, bulb in the left path's small room": 698052, - "Kelp Forest top right area, bulb at the top of the center clearing": 698053, - "Kelp Forest top right area, Black Pearl": 698167, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH: 698048, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_LEFT_OF_THE_CENTER_CLEARING: 698049, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_BIG_ROOM: 698051, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_SMALL_ROOM: 698052, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_CENTER_CLEARING: 698053, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BLACK_PEARL: 698167, } locations_forest_tr_fp = { - "Kelp Forest top right area, bulb in the top fish pass": 698050, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_TOP_FISH_PASS: 698050, } locations_forest_bl = { - "Kelp Forest bottom left area, Transturtle": 698212, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_TRANSTURTLE: 698212, } locations_forest_bl_sc = { - "Kelp Forest bottom left area, bulb close to the spirit crystals": 698054, - "Kelp Forest bottom left area, Walker Baby": 698186, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_BULB_CLOSE_TO_THE_SPIRIT_CRYSTALS: 698054, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_WALKER_BABY: 698186, + } + + locations_forest_fish_cave = { + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_FISH_CAVE_PUZZLE: 698207, } locations_forest_br = { - "Kelp Forest bottom right area, Odd Container": 698168, + AquariaLocationNames.KELP_FOREST_BOTTOM_RIGHT_AREA_ODD_CONTAINER: 698168, } locations_forest_boss = { - "Kelp Forest boss area, beating Drunian God": 698204, + AquariaLocationNames.KELP_FOREST_BOSS_AREA_BEATING_DRUNIAN_GOD: 698204, } locations_forest_boss_entrance = { - "Kelp Forest boss room, bulb at the bottom of the area": 698055, - } - - locations_forest_fish_cave = { - "Kelp Forest bottom left area, Fish Cave puzzle": 698207, + AquariaLocationNames.KELP_FOREST_BOSS_ROOM_BULB_AT_THE_BOTTOM_OF_THE_AREA: 698055, } - locations_forest_sprite_cave = { - "Kelp Forest sprite cave, bulb inside the fish pass": 698056, + locations_sprite_cave = { + AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_BULB_INSIDE_THE_FISH_PASS: 698056, } - locations_forest_sprite_cave_tube = { - "Kelp Forest sprite cave, bulb in the second room": 698057, - "Kelp Forest sprite cave, Seed Bag": 698176, + locations_sprite_cave_tube = { + AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_BULB_IN_THE_SECOND_ROOM: 698057, + AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_SEED_BAG: 698176, } locations_mermog_cave = { - "Mermog cave, bulb in the left part of the cave": 698121, + AquariaLocationNames.MERMOG_CAVE_BULB_IN_THE_LEFT_PART_OF_THE_CAVE: 698121, } locations_mermog_boss = { - "Mermog cave, Piranha Egg": 698197, + AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG: 698197, } locations_veil_tl = { - "The Veil top left area, In Li's cave": 698199, - "The Veil top left area, bulb under the rock in the top right path": 698078, - "The Veil top left area, bulb hidden behind the blocking rock": 698076, - "The Veil top left area, Transturtle": 698209, + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_IN_LI_S_CAVE: 698199, + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_TOP_RIGHT_PATH: 698078, + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_HIDDEN_BEHIND_THE_BLOCKING_ROCK: 698076, + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_TRANSTURTLE: 698209, } locations_veil_tl_fp = { - "The Veil top left area, bulb inside the fish pass": 698077, + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_INSIDE_THE_FISH_PASS: 698077, } locations_turtle_cave = { - "Turtle cave, Turtle Egg": 698184, + AquariaLocationNames.TURTLE_CAVE_TURTLE_EGG: 698184, } locations_turtle_cave_bubble = { - "Turtle cave, bulb in Bubble Cliff": 698000, - "Turtle cave, Urchin Costume": 698193, + AquariaLocationNames.TURTLE_CAVE_BULB_IN_BUBBLE_CLIFF: 698000, + AquariaLocationNames.TURTLE_CAVE_URCHIN_COSTUME: 698193, } locations_veil_tr_r = { - "The Veil top right area, bulb in the middle of the wall jump cliff": 698079, - "The Veil top right area, Golden Starfish": 698180, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_IN_THE_MIDDLE_OF_THE_WALL_JUMP_CLIFF: 698079, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_GOLDEN_STARFISH: 698180, } locations_veil_tr_l = { - "The Veil top right area, bulb at the top of the waterfall": 698080, - "The Veil top right area, Transturtle": 698210, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL: 698080, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_TRANSTURTLE: 698210, } - locations_veil_bl = { - "The Veil bottom area, bulb in the left path": 698082, + locations_veil_b = { + AquariaLocationNames.THE_VEIL_BOTTOM_AREA_BULB_IN_THE_LEFT_PATH: 698082, } locations_veil_b_sc = { - "The Veil bottom area, bulb in the spirit path": 698081, + AquariaLocationNames.THE_VEIL_BOTTOM_AREA_BULB_IN_THE_SPIRIT_PATH: 698081, } - locations_veil_bl_fp = { - "The Veil bottom area, Verse Egg": 698157, + locations_veil_b_fp = { + AquariaLocationNames.THE_VEIL_BOTTOM_AREA_VERSE_EGG: 698157, } locations_veil_br = { - "The Veil bottom area, Stone Head": 698181, + AquariaLocationNames.THE_VEIL_BOTTOM_AREA_STONE_HEAD: 698181, } locations_octo_cave_t = { - "Octopus Cave, Dumbo Egg": 698196, + AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG: 698196, } locations_octo_cave_b = { - "Octopus Cave, bulb in the path below the Octopus Cave path": 698122, + AquariaLocationNames.OCTOPUS_CAVE_BULB_IN_THE_PATH_BELOW_THE_OCTOPUS_CAVE_PATH: 698122, } locations_sun_temple_l = { - "Sun Temple, bulb in the top left part": 698094, - "Sun Temple, bulb in the top right part": 698095, - "Sun Temple, bulb at the top of the high dark room": 698096, - "Sun Temple, Golden Gear": 698171, + AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_LEFT_PART: 698094, + AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_RIGHT_PART: 698095, + AquariaLocationNames.SUN_TEMPLE_BULB_AT_THE_TOP_OF_THE_HIGH_DARK_ROOM: 698096, + AquariaLocationNames.SUN_TEMPLE_GOLDEN_GEAR: 698171, } locations_sun_temple_r = { - "Sun Temple, first bulb of the temple": 698091, - "Sun Temple, bulb on the right part": 698092, - "Sun Temple, bulb in the hidden room of the right part": 698093, - "Sun Temple, Sun Key": 698182, + AquariaLocationNames.SUN_TEMPLE_FIRST_BULB_OF_THE_TEMPLE: 698091, + AquariaLocationNames.SUN_TEMPLE_BULB_ON_THE_RIGHT_PART: 698092, + AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_HIDDEN_ROOM_OF_THE_RIGHT_PART: 698093, + AquariaLocationNames.SUN_TEMPLE_SUN_KEY: 698182, } locations_sun_temple_boss_path = { - "Sun Worm path, first path bulb": 698017, - "Sun Worm path, second path bulb": 698018, - "Sun Worm path, first cliff bulb": 698019, - "Sun Worm path, second cliff bulb": 698020, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_PATH_BULB: 698017, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_PATH_BULB: 698018, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB: 698019, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB: 698020, } locations_sun_temple_boss = { - "Sun Temple boss area, beating Sun God": 698203, + AquariaLocationNames.SUN_TEMPLE_BOSS_AREA_BEATING_LUMEREAN_GOD: 698203, } locations_abyss_l = { - "Abyss left area, bulb in hidden path room": 698024, - "Abyss left area, bulb in the right part": 698025, - "Abyss left area, Glowing Seed": 698166, - "Abyss left area, Glowing Plant": 698172, + AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_HIDDEN_PATH_ROOM: 698024, + AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_THE_RIGHT_PART: 698025, + AquariaLocationNames.ABYSS_LEFT_AREA_GLOWING_SEED: 698166, + AquariaLocationNames.ABYSS_LEFT_AREA_GLOWING_PLANT: 698172, } locations_abyss_lb = { - "Abyss left area, bulb in the bottom fish pass": 698026, + AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_THE_BOTTOM_FISH_PASS: 698026, } locations_abyss_r = { - "Abyss right area, bulb behind the rock in the whale room": 698109, - "Abyss right area, bulb in the middle path": 698110, - "Abyss right area, bulb behind the rock in the middle path": 698111, - "Abyss right area, bulb in the left green room": 698112, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_IN_THE_MIDDLE_PATH: 698110, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_MIDDLE_PATH: 698111, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_IN_THE_LEFT_GREEN_ROOM: 698112, + } + + locations_abyss_r_whale = { + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_WHALE_ROOM: 698109, } locations_abyss_r_transturtle = { - "Abyss right area, Transturtle": 698214, + AquariaLocationNames.ABYSS_RIGHT_AREA_TRANSTURTLE: 698214, } locations_ice_cave = { - "Ice Cave, bulb in the room to the right": 698083, - "Ice Cave, first bulb in the top exit room": 698084, - "Ice Cave, second bulb in the top exit room": 698085, - "Ice Cave, third bulb in the top exit room": 698086, - "Ice Cave, bulb in the left room": 698087, + AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_ROOM_TO_THE_RIGHT: 698083, + AquariaLocationNames.ICE_CAVERN_FIRST_BULB_IN_THE_TOP_EXIT_ROOM: 698084, + AquariaLocationNames.ICE_CAVERN_SECOND_BULB_IN_THE_TOP_EXIT_ROOM: 698085, + AquariaLocationNames.ICE_CAVERN_THIRD_BULB_IN_THE_TOP_EXIT_ROOM: 698086, + AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_LEFT_ROOM: 698087, } locations_bubble_cave = { - "Bubble Cave, bulb in the left cave wall": 698089, - "Bubble Cave, bulb in the right cave wall (behind the ice crystal)": 698090, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL: 698089, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL: 698090, } locations_bubble_cave_boss = { - "Bubble Cave, Verse Egg": 698161, + AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG: 698161, } locations_king_jellyfish_cave = { - "King Jellyfish Cave, bulb in the right path from King Jelly": 698088, - "King Jellyfish Cave, Jellyfish Costume": 698188, + AquariaLocationNames.KING_JELLYFISH_CAVE_BULB_IN_THE_RIGHT_PATH_FROM_KING_JELLY: 698088, + AquariaLocationNames.KING_JELLYFISH_CAVE_JELLYFISH_COSTUME: 698188, } locations_whale = { - "The Whale, Verse Egg": 698159, + AquariaLocationNames.THE_WHALE_VERSE_EGG: 698159, } locations_sunken_city_r = { - "Sunken City right area, crate close to the save crystal": 698154, - "Sunken City right area, crate in the left bottom room": 698155, + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL: 698154, + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_IN_THE_LEFT_BOTTOM_ROOM: 698155, } locations_sunken_city_l = { - "Sunken City left area, crate in the little pipe room": 698151, - "Sunken City left area, crate close to the save crystal": 698152, - "Sunken City left area, crate before the bedroom": 698153, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_IN_THE_LITTLE_PIPE_ROOM: 698151, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL: 698152, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_BEFORE_THE_BEDROOM: 698153, } locations_sunken_city_l_bedroom = { - "Sunken City left area, Girl Costume": 698192, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_GIRL_COSTUME: 698192, } locations_sunken_city_boss = { - "Sunken City, bulb on top of the boss area": 698043, + AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA: 698043, } locations_body_c = { - "The Body center area, breaking Li's cage": 698201, - "The Body center area, bulb on the main path blocking tube": 698097, + AquariaLocationNames.THE_BODY_CENTER_AREA_BREAKING_LI_S_CAGE: 698201, + AquariaLocationNames.THE_BODY_CENTER_AREA_BULB_ON_THE_MAIN_PATH_BLOCKING_TUBE: 698097, } locations_body_l = { - "The Body left area, first bulb in the top face room": 698066, - "The Body left area, second bulb in the top face room": 698069, - "The Body left area, bulb below the water stream": 698067, - "The Body left area, bulb in the top path to the top face room": 698068, - "The Body left area, bulb in the bottom face room": 698070, + AquariaLocationNames.THE_BODY_LEFT_AREA_FIRST_BULB_IN_THE_TOP_FACE_ROOM: 698066, + AquariaLocationNames.THE_BODY_LEFT_AREA_SECOND_BULB_IN_THE_TOP_FACE_ROOM: 698069, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_BELOW_THE_WATER_STREAM: 698067, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_TOP_FACE_ROOM: 698068, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM: 698070, } locations_body_rt = { - "The Body right area, bulb in the top face room": 698100, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_FACE_ROOM: 698100, } locations_body_rb = { - "The Body right area, bulb in the top path to the bottom face room": 698098, - "The Body right area, bulb in the bottom face room": 698099, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_BOTTOM_FACE_ROOM: 698098, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM: 698099, } locations_body_b = { - "The Body bottom area, bulb in the Jelly Zap room": 698101, - "The Body bottom area, bulb in the nautilus room": 698102, - "The Body bottom area, Mutant Costume": 698190, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_JELLY_ZAP_ROOM: 698101, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_NAUTILUS_ROOM: 698102, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_MUTANT_COSTUME: 698190, } locations_final_boss_tube = { - "Final Boss area, first bulb in the turtle room": 698103, - "Final Boss area, second bulb in the turtle room": 698104, - "Final Boss area, third bulb in the turtle room": 698105, - "Final Boss area, Transturtle": 698215, + AquariaLocationNames.FINAL_BOSS_AREA_FIRST_BULB_IN_THE_TURTLE_ROOM: 698103, + AquariaLocationNames.FINAL_BOSS_AREA_SECOND_BULB_IN_THE_TURTLE_ROOM: 698104, + AquariaLocationNames.FINAL_BOSS_AREA_THIRD_BULB_IN_THE_TURTLE_ROOM: 698105, + AquariaLocationNames.FINAL_BOSS_AREA_TRANSTURTLE: 698215, } locations_final_boss = { - "Final Boss area, bulb in the boss third form room": 698106, + AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM: 698106, } @@ -503,11 +812,12 @@ class AquariaLocations: **AquariaLocations.locations_openwater_tl, **AquariaLocations.locations_openwater_tr, **AquariaLocations.locations_openwater_tr_turtle, + **AquariaLocations.locations_openwater_tr_urns, **AquariaLocations.locations_openwater_bl, **AquariaLocations.locations_skeleton_path, **AquariaLocations.locations_skeleton_path_sc, **AquariaLocations.locations_arnassi, - **AquariaLocations.locations_arnassi_path, + **AquariaLocations.locations_arnassi_cave, **AquariaLocations.locations_arnassi_cave_transturtle, **AquariaLocations.locations_arnassi_crab_boss, **AquariaLocations.locations_sun_temple_l, @@ -519,6 +829,7 @@ class AquariaLocations: **AquariaLocations.locations_abyss_l, **AquariaLocations.locations_abyss_lb, **AquariaLocations.locations_abyss_r, + **AquariaLocations.locations_abyss_r_whale, **AquariaLocations.locations_abyss_r_transturtle, **AquariaLocations.locations_energy_temple_1, **AquariaLocations.locations_energy_temple_2, @@ -528,16 +839,20 @@ class AquariaLocations: **AquariaLocations.locations_energy_temple_altar, **AquariaLocations.locations_energy_temple_idol, **AquariaLocations.locations_mithalas_city, + **AquariaLocations.locations_mithalas_city_urns, **AquariaLocations.locations_mithalas_city_top_path, **AquariaLocations.locations_mithalas_city_fishpass, - **AquariaLocations.locations_cathedral_l, - **AquariaLocations.locations_cathedral_l_tube, - **AquariaLocations.locations_cathedral_l_sc, - **AquariaLocations.locations_cathedral_r, + **AquariaLocations.locations_mithalas_castle, + **AquariaLocations.locations_mithalas_castle_urns, + **AquariaLocations.locations_mithalas_castle_tube, + **AquariaLocations.locations_mithalas_castle_sc, + **AquariaLocations.locations_cathedral_top_start, + **AquariaLocations.locations_cathedral_top_start_urns, + **AquariaLocations.locations_cathedral_top_end, **AquariaLocations.locations_cathedral_underground, **AquariaLocations.locations_cathedral_boss, **AquariaLocations.locations_forest_tl, - **AquariaLocations.locations_forest_tl_fp, + **AquariaLocations.locations_forest_tl_verse_egg_room, **AquariaLocations.locations_forest_tr, **AquariaLocations.locations_forest_tr_fp, **AquariaLocations.locations_forest_bl, @@ -545,10 +860,11 @@ class AquariaLocations: **AquariaLocations.locations_forest_br, **AquariaLocations.locations_forest_boss, **AquariaLocations.locations_forest_boss_entrance, - **AquariaLocations.locations_forest_sprite_cave, - **AquariaLocations.locations_forest_sprite_cave_tube, + **AquariaLocations.locations_sprite_cave, + **AquariaLocations.locations_sprite_cave_tube, **AquariaLocations.locations_forest_fish_cave, **AquariaLocations.locations_home_water, + **AquariaLocations.locations_home_water_behind_rocks, **AquariaLocations.locations_home_water_transturtle, **AquariaLocations.locations_home_water_nautilus, **AquariaLocations.locations_body_l, @@ -565,9 +881,9 @@ class AquariaLocations: **AquariaLocations.locations_turtle_cave_bubble, **AquariaLocations.locations_veil_tr_r, **AquariaLocations.locations_veil_tr_l, - **AquariaLocations.locations_veil_bl, + **AquariaLocations.locations_veil_b, **AquariaLocations.locations_veil_b_sc, - **AquariaLocations.locations_veil_bl_fp, + **AquariaLocations.locations_veil_b_fp, **AquariaLocations.locations_veil_br, **AquariaLocations.locations_ice_cave, **AquariaLocations.locations_king_jellyfish_cave, diff --git a/worlds/aquaria/Options.py b/worlds/aquaria/Options.py index 8c0142debff0..c73c108a9544 100644 --- a/worlds/aquaria/Options.py +++ b/worlds/aquaria/Options.py @@ -15,7 +15,10 @@ class IngredientRandomizer(Choice): """ display_name = "Randomize Ingredients" option_off = 0 + alias_false = 0 option_common_ingredients = 1 + alias_on = 1 + alias_true = 1 option_all_ingredients = 2 default = 0 @@ -29,14 +32,43 @@ class TurtleRandomizer(Choice): """Randomize the transportation turtle.""" display_name = "Turtle Randomizer" option_none = 0 + alias_off = 0 + alias_false = 0 option_all = 1 option_all_except_final = 2 + alias_on = 2 + alias_true = 2 default = 2 -class EarlyEnergyForm(DefaultOnToggle): - """ Force the Energy Form to be in a location early in the game """ - display_name = "Early Energy Form" +class EarlyBindSong(Choice): + """ + Force the Bind song to be in a location early in the multiworld (or directly in your world if Early and Local is + selected). + """ + display_name = "Early Bind song" + option_off = 0 + alias_false = 0 + option_early = 1 + alias_on = 1 + alias_true = 1 + option_early_and_local = 2 + default = 1 + + +class EarlyEnergyForm(Choice): + """ + Force the Energy form to be in a location early in the multiworld (or directly in your world if Early and Local is + selected). + """ + display_name = "Early Energy form" + option_off = 0 + alias_false = 0 + option_early = 1 + alias_on = 1 + alias_true = 1 + option_early_and_local = 2 + default = 1 class AquarianTranslation(Toggle): @@ -47,7 +79,7 @@ class AquarianTranslation(Toggle): class BigBossesToBeat(Range): """ The number of big bosses to beat before having access to the creator (the final boss). The big bosses are - "Fallen God", "Mithalan God", "Drunian God", "Sun God" and "The Golem". + "Fallen God", "Mithalan God", "Drunian God", "Lumerean God" and "The Golem". """ display_name = "Big bosses to beat" range_start = 0 @@ -104,7 +136,7 @@ class LightNeededToGetToDarkPlaces(DefaultOnToggle): display_name = "Light needed to get to dark places" -class BindSongNeededToGetUnderRockBulb(Toggle): +class BindSongNeededToGetUnderRockBulb(DefaultOnToggle): """ Make sure that the bind song can be acquired before having to obtain sing bulbs under rocks. """ @@ -121,13 +153,18 @@ class BlindGoal(Toggle): class UnconfineHomeWater(Choice): """ - Open the way out of the Home Water area so that Naija can go to open water and beyond without the bind song. + Open the way out of the Home Waters area so that Naija can go to open water and beyond without the bind song. + Note that if you turn this option off, it is recommended to turn on the Early Energy form and Early Bind Song + options. """ - display_name = "Unconfine Home Water Area" + display_name = "Unconfine Home Waters Area" option_off = 0 + alias_false = 0 option_via_energy_door = 1 option_via_transturtle = 2 option_via_both = 3 + alias_on = 3 + alias_true = 3 default = 0 @@ -142,6 +179,7 @@ class AquariaOptions(PerGameCommonOptions): big_bosses_to_beat: BigBossesToBeat turtle_randomizer: TurtleRandomizer early_energy_form: EarlyEnergyForm + early_bind_song: EarlyBindSong light_needed_to_get_to_dark_places: LightNeededToGetToDarkPlaces bind_song_needed_to_get_under_rock_bulb: BindSongNeededToGetUnderRockBulb unconfine_home_water: UnconfineHomeWater diff --git a/worlds/aquaria/Regions.py b/worlds/aquaria/Regions.py index 7a41e0d0c864..40170e0c3262 100755 --- a/worlds/aquaria/Regions.py +++ b/worlds/aquaria/Regions.py @@ -5,10 +5,10 @@ """ from typing import Dict, Optional -from BaseClasses import MultiWorld, Region, Entrance, ItemClassification, CollectionState -from .Items import AquariaItem -from .Locations import AquariaLocations, AquariaLocation -from .Options import AquariaOptions +from BaseClasses import MultiWorld, Region, Entrance, Item, ItemClassification, CollectionState +from .Items import AquariaItem, ItemNames +from .Locations import AquariaLocations, AquariaLocation, AquariaLocationNames +from .Options import AquariaOptions, UnconfineHomeWater from worlds.generic.Rules import add_rule, set_rule @@ -16,28 +16,28 @@ def _has_hot_soup(state: CollectionState, player: int) -> bool: """`player` in `state` has the hotsoup item""" - return state.has_any({"Hot soup", "Hot soup x 2"}, player) + return state.has_any({ItemNames.HOT_SOUP, ItemNames.HOT_SOUP_X_2}, player) def _has_tongue_cleared(state: CollectionState, player: int) -> bool: """`player` in `state` has the Body tongue cleared item""" - return state.has("Body tongue cleared", player) + return state.has(ItemNames.BODY_TONGUE_CLEARED, player) def _has_sun_crystal(state: CollectionState, player: int) -> bool: """`player` in `state` has the Sun crystal item""" - return state.has("Has sun crystal", player) and _has_bind_song(state, player) + return state.has(ItemNames.HAS_SUN_CRYSTAL, player) and _has_bind_song(state, player) def _has_li(state: CollectionState, player: int) -> bool: """`player` in `state` has Li in its team""" - return state.has("Li and Li song", player) + return state.has(ItemNames.LI_AND_LI_SONG, player) def _has_damaging_item(state: CollectionState, player: int) -> bool: """`player` in `state` has the shield song item""" - return state.has_any({"Energy form", "Nature form", "Beast form", "Li and Li song", "Baby Nautilus", - "Baby Piranha", "Baby Blaster"}, player) + return state.has_any({ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, ItemNames.LI_AND_LI_SONG, + ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, ItemNames.BABY_BLASTER}, player) def _has_energy_attack_item(state: CollectionState, player: int) -> bool: @@ -47,22 +47,22 @@ def _has_energy_attack_item(state: CollectionState, player: int) -> bool: def _has_shield_song(state: CollectionState, player: int) -> bool: """`player` in `state` has the shield song item""" - return state.has("Shield song", player) + return state.has(ItemNames.SHIELD_SONG, player) def _has_bind_song(state: CollectionState, player: int) -> bool: """`player` in `state` has the bind song item""" - return state.has("Bind song", player) + return state.has(ItemNames.BIND_SONG, player) def _has_energy_form(state: CollectionState, player: int) -> bool: """`player` in `state` has the energy form item""" - return state.has("Energy form", player) + return state.has(ItemNames.ENERGY_FORM, player) def _has_beast_form(state: CollectionState, player: int) -> bool: """`player` in `state` has the beast form item""" - return state.has("Beast form", player) + return state.has(ItemNames.BEAST_FORM, player) def _has_beast_and_soup_form(state: CollectionState, player: int) -> bool: @@ -72,55 +72,61 @@ def _has_beast_and_soup_form(state: CollectionState, player: int) -> bool: def _has_beast_form_or_arnassi_armor(state: CollectionState, player: int) -> bool: """`player` in `state` has the beast form item""" - return _has_beast_form(state, player) or state.has("Arnassi Armor", player) + return _has_beast_form(state, player) or state.has(ItemNames.ARNASSI_ARMOR, player) def _has_nature_form(state: CollectionState, player: int) -> bool: """`player` in `state` has the nature form item""" - return state.has("Nature form", player) + return state.has(ItemNames.NATURE_FORM, player) def _has_sun_form(state: CollectionState, player: int) -> bool: """`player` in `state` has the sun form item""" - return state.has("Sun form", player) + return state.has(ItemNames.SUN_FORM, player) def _has_light(state: CollectionState, player: int) -> bool: """`player` in `state` has the light item""" - return state.has("Baby Dumbo", player) or _has_sun_form(state, player) + return state.has(ItemNames.BABY_DUMBO, player) or _has_sun_form(state, player) def _has_dual_form(state: CollectionState, player: int) -> bool: """`player` in `state` has the dual form item""" - return _has_li(state, player) and state.has("Dual form", player) + return _has_li(state, player) and state.has(ItemNames.DUAL_FORM, player) def _has_fish_form(state: CollectionState, player: int) -> bool: """`player` in `state` has the fish form item""" - return state.has("Fish form", player) + return state.has(ItemNames.FISH_FORM, player) def _has_spirit_form(state: CollectionState, player: int) -> bool: """`player` in `state` has the spirit form item""" - return state.has("Spirit form", player) + return state.has(ItemNames.SPIRIT_FORM, player) def _has_big_bosses(state: CollectionState, player: int) -> bool: """`player` in `state` has beated every big bosses""" - return state.has_all({"Fallen God beated", "Mithalan God beated", "Drunian God beated", - "Sun God beated", "The Golem beated"}, player) + return state.has_all({ItemNames.FALLEN_GOD_BEATED, ItemNames.MITHALAN_GOD_BEATED, ItemNames.DRUNIAN_GOD_BEATED, + ItemNames.LUMEREAN_GOD_BEATED, ItemNames.THE_GOLEM_BEATED}, player) def _has_mini_bosses(state: CollectionState, player: int) -> bool: """`player` in `state` has beated every big bosses""" - return state.has_all({"Nautilus Prime beated", "Blaster Peg Prime beated", "Mergog beated", - "Mithalan priests beated", "Octopus Prime beated", "Crabbius Maximus beated", - "Mantis Shrimp Prime beated", "King Jellyfish God Prime beated"}, player) + return state.has_all({ItemNames.NAUTILUS_PRIME_BEATED, ItemNames.BLASTER_PEG_PRIME_BEATED, ItemNames.MERGOG_BEATED, + ItemNames.MITHALAN_PRIESTS_BEATED, ItemNames.OCTOPUS_PRIME_BEATED, + ItemNames.CRABBIUS_MAXIMUS_BEATED, ItemNames.MANTIS_SHRIMP_PRIME_BEATED, + ItemNames.KING_JELLYFISH_GOD_PRIME_BEATED}, player) def _has_secrets(state: CollectionState, player: int) -> bool: - return state.has_all({"First secret obtained", "Second secret obtained", "Third secret obtained"}, player) + """The secrets have been acquired in the `state` of the `player`""" + return state.has_all({ItemNames.FIRST_SECRET_OBTAINED, ItemNames.SECOND_SECRET_OBTAINED, + ItemNames.THIRD_SECRET_OBTAINED}, player) +def _item_not_advancement(item: Item): + """The `item` is not an advancement item""" + return not item.advancement class AquariaRegions: """ @@ -130,6 +136,7 @@ class AquariaRegions: verse_cave_r: Region verse_cave_l: Region home_water: Region + home_water_behind_rocks: Region home_water_nautilus: Region home_water_transturtle: Region naija_home: Region @@ -138,33 +145,40 @@ class AquariaRegions: energy_temple_2: Region energy_temple_3: Region energy_temple_boss: Region + energy_temple_4: Region energy_temple_idol: Region energy_temple_blaster_room: Region energy_temple_altar: Region openwater_tl: Region openwater_tr: Region openwater_tr_turtle: Region + openwater_tr_urns: Region openwater_bl: Region openwater_br: Region skeleton_path: Region skeleton_path_sc: Region arnassi: Region arnassi_cave_transturtle: Region - arnassi_path: Region + arnassi_cave: Region arnassi_crab_boss: Region simon: Region mithalas_city: Region + mithalas_city_urns: Region mithalas_city_top_path: Region mithalas_city_fishpass: Region - cathedral_l: Region - cathedral_l_tube: Region - cathedral_l_sc: Region - cathedral_r: Region + mithalas_castle: Region + mithalas_castle_urns: Region + mithalas_castle_tube: Region + mithalas_castle_sc: Region + cathedral_top: Region + cathedral_top_start: Region + cathedral_top_start_urns: Region + cathedral_top_end: Region cathedral_underground: Region cathedral_boss_l: Region cathedral_boss_r: Region forest_tl: Region - forest_tl_fp: Region + forest_tl_verse_egg_room: Region forest_tr: Region forest_tr_fp: Region forest_bl: Region @@ -172,24 +186,26 @@ class AquariaRegions: forest_br: Region forest_boss: Region forest_boss_entrance: Region - forest_sprite_cave: Region - forest_sprite_cave_tube: Region + sprite_cave: Region + sprite_cave_tube: Region mermog_cave: Region mermog_boss: Region forest_fish_cave: Region veil_tl: Region veil_tl_fp: Region veil_tr_l: Region + veil_tr_l_fp: Region veil_tr_r: Region - veil_bl: Region + veil_b: Region veil_b_sc: Region - veil_bl_fp: Region + veil_b_fp: Region veil_br: Region octo_cave_t: Region octo_cave_b: Region turtle_cave: Region turtle_cave_bubble: Region sun_temple_l: Region + sun_temple_l_entrance: Region sun_temple_r: Region sun_temple_boss_path: Region sun_temple_boss: Region @@ -198,13 +214,16 @@ class AquariaRegions: abyss_r: Region abyss_r_transturtle: Region ice_cave: Region + frozen_feil: Region bubble_cave: Region bubble_cave_boss: Region king_jellyfish_cave: Region + abyss_r_whale: Region whale: Region first_secret: Region sunken_city_l: Region - sunken_city_r: Region + sunken_city_l_crates: Region + sunken_city_r_crates: Region sunken_city_boss: Region sunken_city_l_bedroom: Region body_c: Region @@ -250,11 +269,13 @@ def __create_home_water_area(self) -> None: AquariaLocations.locations_verse_cave_r) self.verse_cave_l = self.__add_region("Verse Cave left area", AquariaLocations.locations_verse_cave_l) - self.home_water = self.__add_region("Home Water", AquariaLocations.locations_home_water) - self.home_water_nautilus = self.__add_region("Home Water, Nautilus nest", + self.home_water = self.__add_region("Home Waters", AquariaLocations.locations_home_water) + self.home_water_nautilus = self.__add_region("Home Waters, Nautilus nest", AquariaLocations.locations_home_water_nautilus) - self.home_water_transturtle = self.__add_region("Home Water, turtle room", + self.home_water_transturtle = self.__add_region("Home Waters, turtle room", AquariaLocations.locations_home_water_transturtle) + self.home_water_behind_rocks = self.__add_region("Home Waters, behind rock", + AquariaLocations.locations_home_water_behind_rocks) self.naija_home = self.__add_region("Naija's Home", AquariaLocations.locations_naija_home) self.song_cave = self.__add_region("Song Cave", AquariaLocations.locations_song_cave) @@ -276,29 +297,32 @@ def __create_energy_temple(self) -> None: AquariaLocations.locations_energy_temple_idol) self.energy_temple_blaster_room = self.__add_region("Energy Temple blaster room", AquariaLocations.locations_energy_temple_blaster_room) + self.energy_temple_4 = self.__add_region("Energy Temple after boss path", None) def __create_openwater(self) -> None: """ Create the `openwater_*`, `skeleton_path`, `arnassi*` and `simon` regions """ - self.openwater_tl = self.__add_region("Open Water top left area", + self.openwater_tl = self.__add_region("Open Waters top left area", AquariaLocations.locations_openwater_tl) - self.openwater_tr = self.__add_region("Open Water top right area", + self.openwater_tr = self.__add_region("Open Waters top right area", AquariaLocations.locations_openwater_tr) - self.openwater_tr_turtle = self.__add_region("Open Water top right area, turtle room", + self.openwater_tr_turtle = self.__add_region("Open Waters top right area, turtle room", AquariaLocations.locations_openwater_tr_turtle) - self.openwater_bl = self.__add_region("Open Water bottom left area", + self.openwater_tr_urns = self.__add_region("Open Waters top right area, Mithalas entrance", + AquariaLocations.locations_openwater_tr_urns) + self.openwater_bl = self.__add_region("Open Waters bottom left area", AquariaLocations.locations_openwater_bl) - self.openwater_br = self.__add_region("Open Water bottom right area", None) - self.skeleton_path = self.__add_region("Open Water skeleton path", + self.openwater_br = self.__add_region("Open Waters bottom right area", None) + self.skeleton_path = self.__add_region("Open Waters skeleton path", AquariaLocations.locations_skeleton_path) - self.skeleton_path_sc = self.__add_region("Open Water skeleton path spirit crystal", + self.skeleton_path_sc = self.__add_region("Open Waters skeleton path spirit crystal", AquariaLocations.locations_skeleton_path_sc) self.arnassi = self.__add_region("Arnassi Ruins", AquariaLocations.locations_arnassi) - self.arnassi_path = self.__add_region("Arnassi Ruins, back entrance path", - AquariaLocations.locations_arnassi_path) - self.arnassi_cave_transturtle = self.__add_region("Arnassi Ruins, transturtle area", + self.arnassi_cave = self.__add_region("Arnassi Ruins cave", + AquariaLocations.locations_arnassi_cave) + self.arnassi_cave_transturtle = self.__add_region("Arnassi Ruins cave, transturtle area", AquariaLocations.locations_arnassi_cave_transturtle) self.arnassi_crab_boss = self.__add_region("Arnassi Ruins, Crabbius Maximus lair", AquariaLocations.locations_arnassi_crab_boss) @@ -309,22 +333,29 @@ def __create_mithalas(self) -> None: """ self.mithalas_city = self.__add_region("Mithalas City", AquariaLocations.locations_mithalas_city) + self.mithalas_city_urns = self.__add_region("Mithalas City urns", AquariaLocations.locations_mithalas_city_urns) self.mithalas_city_fishpass = self.__add_region("Mithalas City fish pass", AquariaLocations.locations_mithalas_city_fishpass) self.mithalas_city_top_path = self.__add_region("Mithalas City top path", AquariaLocations.locations_mithalas_city_top_path) - self.cathedral_l = self.__add_region("Mithalas castle", AquariaLocations.locations_cathedral_l) - self.cathedral_l_tube = self.__add_region("Mithalas castle, plant tube entrance", - AquariaLocations.locations_cathedral_l_tube) - self.cathedral_l_sc = self.__add_region("Mithalas castle spirit crystal", - AquariaLocations.locations_cathedral_l_sc) - self.cathedral_r = self.__add_region("Mithalas Cathedral", - AquariaLocations.locations_cathedral_r) + self.mithalas_castle = self.__add_region("Mithalas castle", AquariaLocations.locations_mithalas_castle) + self.mithalas_castle_urns = self.__add_region("Mithalas castle urns", + AquariaLocations.locations_mithalas_castle_urns) + self.mithalas_castle_tube = self.__add_region("Mithalas castle, plant tube entrance", + AquariaLocations.locations_mithalas_castle_tube) + self.mithalas_castle_sc = self.__add_region("Mithalas castle spirit crystal", + AquariaLocations.locations_mithalas_castle_sc) + self.cathedral_top_start = self.__add_region("Mithalas Cathedral start", + AquariaLocations.locations_cathedral_top_start) + self.cathedral_top_start_urns = self.__add_region("Mithalas Cathedral start urns", + AquariaLocations.locations_cathedral_top_start_urns) + self.cathedral_top_end = self.__add_region("Mithalas Cathedral end", + AquariaLocations.locations_cathedral_top_end) self.cathedral_underground = self.__add_region("Mithalas Cathedral underground", AquariaLocations.locations_cathedral_underground) - self.cathedral_boss_r = self.__add_region("Mithalas Cathedral, Mithalan God room", None) - self.cathedral_boss_l = self.__add_region("Mithalas Cathedral, after Mithalan God room", + self.cathedral_boss_l = self.__add_region("Mithalas Cathedral, after Mithalan God", AquariaLocations.locations_cathedral_boss) + self.cathedral_boss_r = self.__add_region("Mithalas Cathedral, before Mithalan God", None) def __create_forest(self) -> None: """ @@ -332,8 +363,8 @@ def __create_forest(self) -> None: """ self.forest_tl = self.__add_region("Kelp Forest top left area", AquariaLocations.locations_forest_tl) - self.forest_tl_fp = self.__add_region("Kelp Forest top left area fish pass", - AquariaLocations.locations_forest_tl_fp) + self.forest_tl_verse_egg_room = self.__add_region("Kelp Forest top left area fish pass", + AquariaLocations.locations_forest_tl_verse_egg_room) self.forest_tr = self.__add_region("Kelp Forest top right area", AquariaLocations.locations_forest_tr) self.forest_tr_fp = self.__add_region("Kelp Forest top right area fish pass", @@ -344,21 +375,21 @@ def __create_forest(self) -> None: AquariaLocations.locations_forest_bl_sc) self.forest_br = self.__add_region("Kelp Forest bottom right area", AquariaLocations.locations_forest_br) - self.forest_sprite_cave = self.__add_region("Kelp Forest spirit cave", - AquariaLocations.locations_forest_sprite_cave) - self.forest_sprite_cave_tube = self.__add_region("Kelp Forest spirit cave after the plant tube", - AquariaLocations.locations_forest_sprite_cave_tube) + self.sprite_cave = self.__add_region("Sprite cave", + AquariaLocations.locations_sprite_cave) + self.sprite_cave_tube = self.__add_region("Sprite cave after the plant tube", + AquariaLocations.locations_sprite_cave_tube) self.forest_boss = self.__add_region("Kelp Forest Drunian God room", AquariaLocations.locations_forest_boss) self.forest_boss_entrance = self.__add_region("Kelp Forest Drunian God room entrance", AquariaLocations.locations_forest_boss_entrance) - self.mermog_cave = self.__add_region("Kelp Forest Mermog cave", + self.mermog_cave = self.__add_region("Mermog cave", AquariaLocations.locations_mermog_cave) - self.mermog_boss = self.__add_region("Kelp Forest Mermog cave boss", + self.mermog_boss = self.__add_region("Mermog cave boss", AquariaLocations.locations_mermog_boss) self.forest_fish_cave = self.__add_region("Kelp Forest fish cave", AquariaLocations.locations_forest_fish_cave) - self.simon = self.__add_region("Kelp Forest, Simon's room", AquariaLocations.locations_simon) + self.simon = self.__add_region("Simon Says area", AquariaLocations.locations_simon) def __create_veil(self) -> None: """ @@ -373,18 +404,19 @@ def __create_veil(self) -> None: AquariaLocations.locations_turtle_cave_bubble) self.veil_tr_l = self.__add_region("The Veil top right area, left of temple", AquariaLocations.locations_veil_tr_l) + self.veil_tr_l_fp = self.__add_region("The Veil top right area, fish pass left of temple", None) self.veil_tr_r = self.__add_region("The Veil top right area, right of temple", AquariaLocations.locations_veil_tr_r) self.octo_cave_t = self.__add_region("Octopus Cave top entrance", AquariaLocations.locations_octo_cave_t) self.octo_cave_b = self.__add_region("Octopus Cave bottom entrance", AquariaLocations.locations_octo_cave_b) - self.veil_bl = self.__add_region("The Veil bottom left area", - AquariaLocations.locations_veil_bl) + self.veil_b = self.__add_region("The Veil bottom left area", + AquariaLocations.locations_veil_b) self.veil_b_sc = self.__add_region("The Veil bottom spirit crystal area", AquariaLocations.locations_veil_b_sc) - self.veil_bl_fp = self.__add_region("The Veil bottom left area, in the sunken ship", - AquariaLocations.locations_veil_bl_fp) + self.veil_b_fp = self.__add_region("The Veil bottom left area, in the sunken ship", + AquariaLocations.locations_veil_b_fp) self.veil_br = self.__add_region("The Veil bottom right area", AquariaLocations.locations_veil_br) @@ -394,6 +426,7 @@ def __create_sun_temple(self) -> None: """ self.sun_temple_l = self.__add_region("Sun Temple left area", AquariaLocations.locations_sun_temple_l) + self.sun_temple_l_entrance = self.__add_region("Sun Temple left area entrance", None) self.sun_temple_r = self.__add_region("Sun Temple right area", AquariaLocations.locations_sun_temple_r) self.sun_temple_boss_path = self.__add_region("Sun Temple before boss area", @@ -412,24 +445,29 @@ def __create_abyss(self) -> None: self.abyss_r = self.__add_region("Abyss right area", AquariaLocations.locations_abyss_r) self.abyss_r_transturtle = self.__add_region("Abyss right area, transturtle", AquariaLocations.locations_abyss_r_transturtle) - self.ice_cave = self.__add_region("Ice Cave", AquariaLocations.locations_ice_cave) + self.abyss_r_whale = self.__add_region("Abyss right area, outside the whale", + AquariaLocations.locations_abyss_r_whale) + self.ice_cave = self.__add_region("Ice Cavern", AquariaLocations.locations_ice_cave) + self.frozen_feil = self.__add_region("Frozen Veil", None) self.bubble_cave = self.__add_region("Bubble Cave", AquariaLocations.locations_bubble_cave) self.bubble_cave_boss = self.__add_region("Bubble Cave boss area", AquariaLocations.locations_bubble_cave_boss) self.king_jellyfish_cave = self.__add_region("Abyss left area, King jellyfish cave", AquariaLocations.locations_king_jellyfish_cave) self.whale = self.__add_region("Inside the whale", AquariaLocations.locations_whale) - self.first_secret = self.__add_region("First secret area", None) + self.first_secret = self.__add_region("First Secret area", None) def __create_sunken_city(self) -> None: """ Create the `sunken_city_*` regions """ - self.sunken_city_l = self.__add_region("Sunken City left area", - AquariaLocations.locations_sunken_city_l) + self.sunken_city_l = self.__add_region("Sunken City left area", None) + self.sunken_city_l_crates = self.__add_region("Sunken City left area", + AquariaLocations.locations_sunken_city_l) self.sunken_city_l_bedroom = self.__add_region("Sunken City left area, bedroom", AquariaLocations.locations_sunken_city_l_bedroom) - self.sunken_city_r = self.__add_region("Sunken City right area", - AquariaLocations.locations_sunken_city_r) + self.sunken_city_r = self.__add_region("Sunken City right area", None) + self.sunken_city_r_crates = self.__add_region("Sunken City right area crates", + AquariaLocations.locations_sunken_city_r) self.sunken_city_boss = self.__add_region("Sunken City boss area", AquariaLocations.locations_sunken_city_boss) @@ -454,249 +492,194 @@ def __create_body(self) -> None: AquariaLocations.locations_final_boss) self.final_boss_end = self.__add_region("The Body, final boss area", None) - def __connect_one_way_regions(self, source_name: str, destination_name: str, - source_region: Region, - destination_region: Region, rule=None) -> None: + def get_entrance_name(self, from_region: Region, to_region: Region): + """ + Return the name of an entrance between `from_region` and `to_region` + """ + return from_region.name + " to " + to_region.name + + def __connect_one_way_regions(self, source_region: Region, destination_region: Region, rule=None) -> None: """ Connect from the `source_region` to the `destination_region` """ - entrance = Entrance(source_region.player, source_name + " to " + destination_name, source_region) + entrance = Entrance(self.player, self.get_entrance_name(source_region, destination_region), source_region) source_region.exits.append(entrance) entrance.connect(destination_region) if rule is not None: set_rule(entrance, rule) - def __connect_regions(self, source_name: str, destination_name: str, - source_region: Region, + def __connect_regions(self, source_region: Region, destination_region: Region, rule=None) -> None: """ Connect the `source_region` and the `destination_region` (two-way) """ - self.__connect_one_way_regions(source_name, destination_name, source_region, destination_region, rule) - self.__connect_one_way_regions(destination_name, source_name, destination_region, source_region, rule) + self.__connect_one_way_regions(source_region, destination_region, rule) + self.__connect_one_way_regions(destination_region, source_region, rule) def __connect_home_water_regions(self) -> None: """ Connect entrances of the different regions around `home_water` """ - self.__connect_one_way_regions("Menu", "Verse Cave right area", - self.menu, self.verse_cave_r) - self.__connect_regions("Verse Cave left area", "Verse Cave right area", - self.verse_cave_l, self.verse_cave_r) - self.__connect_regions("Verse Cave", "Home Water", self.verse_cave_l, self.home_water) - self.__connect_regions("Home Water", "Haija's home", self.home_water, self.naija_home) - self.__connect_regions("Home Water", "Song Cave", self.home_water, self.song_cave) - self.__connect_regions("Home Water", "Home Water, nautilus nest", - self.home_water, self.home_water_nautilus, - lambda state: _has_energy_attack_item(state, self.player) and - _has_bind_song(state, self.player)) - self.__connect_regions("Home Water", "Home Water transturtle room", - self.home_water, self.home_water_transturtle) - self.__connect_regions("Home Water", "Energy Temple first area", - self.home_water, self.energy_temple_1, + self.__connect_one_way_regions(self.menu, self.verse_cave_r) + self.__connect_regions(self.verse_cave_l, self.verse_cave_r) + self.__connect_regions(self.verse_cave_l, self.home_water) + self.__connect_regions(self.home_water, self.naija_home) + self.__connect_regions(self.home_water, self.song_cave) + self.__connect_regions(self.home_water, self.home_water_behind_rocks, lambda state: _has_bind_song(state, self.player)) - self.__connect_regions("Home Water", "Energy Temple_altar", - self.home_water, self.energy_temple_altar, + self.__connect_regions(self.home_water_behind_rocks, self.home_water_nautilus, + lambda state: _has_energy_attack_item(state, self.player)) + self.__connect_regions(self.home_water, self.home_water_transturtle) + self.__connect_regions(self.home_water_behind_rocks, self.energy_temple_1) + self.__connect_regions(self.home_water_behind_rocks, self.energy_temple_altar, lambda state: _has_energy_attack_item(state, self.player) and _has_bind_song(state, self.player)) - self.__connect_regions("Energy Temple first area", "Energy Temple second area", - self.energy_temple_1, self.energy_temple_2, + self.__connect_regions(self.energy_temple_1, self.energy_temple_2, lambda state: _has_energy_form(state, self.player)) - self.__connect_regions("Energy Temple first area", "Energy Temple idol room", - self.energy_temple_1, self.energy_temple_idol, + self.__connect_regions(self.energy_temple_1, self.energy_temple_idol, lambda state: _has_fish_form(state, self.player)) - self.__connect_regions("Energy Temple idol room", "Energy Temple boss area", - self.energy_temple_idol, self.energy_temple_boss, + self.__connect_regions(self.energy_temple_idol, self.energy_temple_boss, lambda state: _has_energy_attack_item(state, self.player) and _has_fish_form(state, self.player)) - self.__connect_one_way_regions("Energy Temple first area", "Energy Temple boss area", - self.energy_temple_1, self.energy_temple_boss, - lambda state: _has_beast_form(state, self.player) and + self.__connect_one_way_regions(self.energy_temple_1, self.energy_temple_4, + lambda state: _has_beast_form(state, self.player)) + self.__connect_one_way_regions(self.energy_temple_4, self.energy_temple_1) + self.__connect_regions(self.energy_temple_4, self.energy_temple_boss, + lambda state: _has_energy_attack_item(state, self.player)) + self.__connect_regions(self.energy_temple_2, self.energy_temple_3) + self.__connect_one_way_regions(self.energy_temple_3, self.energy_temple_boss, + lambda state: _has_bind_song(state, self.player) and _has_energy_attack_item(state, self.player)) - self.__connect_one_way_regions("Energy Temple boss area", "Energy Temple first area", - self.energy_temple_boss, self.energy_temple_1, - lambda state: _has_energy_attack_item(state, self.player)) - self.__connect_regions("Energy Temple second area", "Energy Temple third area", - self.energy_temple_2, self.energy_temple_3, - lambda state: _has_energy_form(state, self.player)) - self.__connect_regions("Energy Temple boss area", "Energy Temple blaster room", - self.energy_temple_boss, self.energy_temple_blaster_room, - lambda state: _has_nature_form(state, self.player) and - _has_bind_song(state, self.player) and - _has_energy_attack_item(state, self.player)) - self.__connect_regions("Energy Temple first area", "Energy Temple blaster room", - self.energy_temple_1, self.energy_temple_blaster_room, - lambda state: _has_nature_form(state, self.player) and - _has_bind_song(state, self.player) and - _has_energy_attack_item(state, self.player) and - _has_beast_form(state, self.player)) - self.__connect_regions("Home Water", "Open Water top left area", - self.home_water, self.openwater_tl) + self.__connect_one_way_regions(self.energy_temple_4, self.energy_temple_blaster_room, + lambda state: _has_nature_form(state, self.player) and + _has_bind_song(state, self.player) and + _has_energy_attack_item(state, self.player)) + self.__connect_regions(self.home_water, self.openwater_tl) def __connect_open_water_regions(self) -> None: """ Connect entrances of the different regions around open water """ - self.__connect_regions("Open Water top left area", "Open Water top right area", - self.openwater_tl, self.openwater_tr) - self.__connect_regions("Open Water top left area", "Open Water bottom left area", - self.openwater_tl, self.openwater_bl) - self.__connect_regions("Open Water top left area", "forest bottom right area", - self.openwater_tl, self.forest_br) - self.__connect_regions("Open Water top right area", "Open Water top right area, turtle room", - self.openwater_tr, self.openwater_tr_turtle, - lambda state: _has_beast_form_or_arnassi_armor(state, self.player)) - self.__connect_regions("Open Water top right area", "Open Water bottom right area", - self.openwater_tr, self.openwater_br) - self.__connect_regions("Open Water top right area", "Mithalas City", - self.openwater_tr, self.mithalas_city) - self.__connect_regions("Open Water top right area", "Veil bottom left area", - self.openwater_tr, self.veil_bl) - self.__connect_one_way_regions("Open Water top right area", "Veil bottom right", - self.openwater_tr, self.veil_br, + self.__connect_regions(self.openwater_tl, self.openwater_tr) + self.__connect_regions(self.openwater_tl, self.openwater_bl) + self.__connect_regions(self.openwater_tl, self.forest_br) + self.__connect_one_way_regions(self.openwater_tr, self.openwater_tr_turtle, lambda state: _has_beast_form_or_arnassi_armor(state, self.player)) - self.__connect_one_way_regions("Veil bottom right", "Open Water top right area", - self.veil_br, self.openwater_tr) - self.__connect_regions("Open Water bottom left area", "Open Water bottom right area", - self.openwater_bl, self.openwater_br) - self.__connect_regions("Open Water bottom left area", "Skeleton path", - self.openwater_bl, self.skeleton_path) - self.__connect_regions("Abyss left area", "Open Water bottom left area", - self.abyss_l, self.openwater_bl) - self.__connect_regions("Skeleton path", "skeleton_path_sc", - self.skeleton_path, self.skeleton_path_sc, + self.__connect_one_way_regions(self.openwater_tr_turtle, self.openwater_tr) + self.__connect_one_way_regions(self.openwater_tr, self.openwater_tr_urns, + lambda state: _has_bind_song(state, self.player) or + _has_damaging_item(state, self.player)) + self.__connect_regions(self.openwater_tr, self.openwater_br) + self.__connect_regions(self.openwater_tr, self.mithalas_city) + self.__connect_regions(self.openwater_tr, self.veil_b) + self.__connect_one_way_regions(self.openwater_tr, self.veil_br, + lambda state: _has_beast_form_or_arnassi_armor(state, self.player)) + self.__connect_one_way_regions(self.veil_br, self.openwater_tr) + self.__connect_regions(self.openwater_bl, self.openwater_br) + self.__connect_regions(self.openwater_bl, self.skeleton_path) + self.__connect_regions(self.abyss_l, self.openwater_bl) + self.__connect_regions(self.skeleton_path, self.skeleton_path_sc, lambda state: _has_spirit_form(state, self.player)) - self.__connect_regions("Abyss right area", "Open Water bottom right area", - self.abyss_r, self.openwater_br) - self.__connect_one_way_regions("Open Water bottom right area", "Arnassi", - self.openwater_br, self.arnassi, + self.__connect_regions(self.abyss_r, self.openwater_br) + self.__connect_one_way_regions(self.openwater_br, self.arnassi, lambda state: _has_beast_form(state, self.player)) - self.__connect_one_way_regions("Arnassi", "Open Water bottom right area", - self.arnassi, self.openwater_br) - self.__connect_regions("Arnassi", "Arnassi path", - self.arnassi, self.arnassi_path) - self.__connect_regions("Arnassi ruins, transturtle area", "Arnassi path", - self.arnassi_cave_transturtle, self.arnassi_path, + self.__connect_one_way_regions(self.arnassi, self.openwater_br) + self.__connect_regions(self.arnassi, self.arnassi_cave) + self.__connect_regions(self.arnassi_cave_transturtle, self.arnassi_cave, lambda state: _has_fish_form(state, self.player)) - self.__connect_one_way_regions("Arnassi path", "Arnassi crab boss area", - self.arnassi_path, self.arnassi_crab_boss, + self.__connect_one_way_regions(self.arnassi_cave, self.arnassi_crab_boss, lambda state: _has_beast_form_or_arnassi_armor(state, self.player) and (_has_energy_attack_item(state, self.player) or _has_nature_form(state, self.player))) - self.__connect_one_way_regions("Arnassi crab boss area", "Arnassi path", - self.arnassi_crab_boss, self.arnassi_path) + self.__connect_one_way_regions(self.arnassi_crab_boss, self.arnassi_cave) def __connect_mithalas_regions(self) -> None: """ Connect entrances of the different regions around Mithalas """ - self.__connect_one_way_regions("Mithalas City", "Mithalas City top path", - self.mithalas_city, self.mithalas_city_top_path, + self.__connect_one_way_regions(self.mithalas_city, self.mithalas_city_urns, + lambda state: _has_damaging_item(state, self.player)) + self.__connect_one_way_regions(self.mithalas_city, self.mithalas_city_top_path, lambda state: _has_beast_form_or_arnassi_armor(state, self.player)) - self.__connect_one_way_regions("Mithalas City_top_path", "Mithalas City", - self.mithalas_city_top_path, self.mithalas_city) - self.__connect_regions("Mithalas City", "Mithalas City home with fishpass", - self.mithalas_city, self.mithalas_city_fishpass, + self.__connect_one_way_regions(self.mithalas_city_top_path, self.mithalas_city) + self.__connect_regions(self.mithalas_city, self.mithalas_city_fishpass, lambda state: _has_fish_form(state, self.player)) - self.__connect_regions("Mithalas City", "Mithalas castle", - self.mithalas_city, self.cathedral_l) - self.__connect_one_way_regions("Mithalas City top path", "Mithalas castle, flower tube", - self.mithalas_city_top_path, - self.cathedral_l_tube, + self.__connect_regions(self.mithalas_city, self.mithalas_castle) + self.__connect_one_way_regions(self.mithalas_city_top_path, + self.mithalas_castle_tube, lambda state: _has_nature_form(state, self.player) and _has_energy_attack_item(state, self.player)) - self.__connect_one_way_regions("Mithalas castle, flower tube area", "Mithalas City top path", - self.cathedral_l_tube, + self.__connect_one_way_regions(self.mithalas_castle_tube, self.mithalas_city_top_path, lambda state: _has_nature_form(state, self.player)) - self.__connect_one_way_regions("Mithalas castle flower tube area", "Mithalas castle, spirit crystals", - self.cathedral_l_tube, self.cathedral_l_sc, + self.__connect_one_way_regions(self.mithalas_castle_tube, self.mithalas_castle_sc, lambda state: _has_spirit_form(state, self.player)) - self.__connect_one_way_regions("Mithalas castle_flower tube area", "Mithalas castle", - self.cathedral_l_tube, self.cathedral_l, + self.__connect_one_way_regions(self.mithalas_castle_tube, self.mithalas_castle, lambda state: _has_spirit_form(state, self.player)) - self.__connect_regions("Mithalas castle", "Mithalas castle, spirit crystals", - self.cathedral_l, self.cathedral_l_sc, + self.__connect_one_way_regions(self.mithalas_castle, self.mithalas_castle_urns, + lambda state: _has_damaging_item(state, self.player)) + self.__connect_regions(self.mithalas_castle, self.mithalas_castle_sc, lambda state: _has_spirit_form(state, self.player)) - self.__connect_one_way_regions("Mithalas castle", "Cathedral boss right area", - self.cathedral_l, self.cathedral_boss_r, + self.__connect_one_way_regions(self.mithalas_castle, self.cathedral_boss_r, lambda state: _has_beast_form(state, self.player)) - self.__connect_one_way_regions("Cathedral boss left area", "Mithalas castle", - self.cathedral_boss_l, self.cathedral_l, + self.__connect_one_way_regions(self.cathedral_boss_l, self.mithalas_castle, lambda state: _has_beast_form(state, self.player)) - self.__connect_regions("Mithalas castle", "Mithalas Cathedral underground", - self.cathedral_l, self.cathedral_underground, + self.__connect_regions(self.mithalas_castle, self.cathedral_underground, lambda state: _has_beast_form(state, self.player)) - self.__connect_one_way_regions("Mithalas castle", "Mithalas Cathedral", - self.cathedral_l, self.cathedral_r, - lambda state: _has_bind_song(state, self.player) and - _has_energy_attack_item(state, self.player)) - self.__connect_one_way_regions("Mithalas Cathedral", "Mithalas Cathedral underground", - self.cathedral_r, self.cathedral_underground) - self.__connect_one_way_regions("Mithalas Cathedral underground", "Mithalas Cathedral", - self.cathedral_underground, self.cathedral_r, + self.__connect_one_way_regions(self.mithalas_castle, self.cathedral_top_start, + lambda state: _has_bind_song(state, self.player)) + self.__connect_one_way_regions(self.cathedral_top_start, self.cathedral_top_start_urns, + lambda state: _has_damaging_item(state, self.player)) + self.__connect_regions(self.cathedral_top_start, self.cathedral_top_end, + lambda state: _has_energy_attack_item(state, self.player)) + self.__connect_one_way_regions(self.cathedral_underground, self.cathedral_top_end, lambda state: _has_beast_form(state, self.player) and - _has_energy_attack_item(state, self.player)) - self.__connect_one_way_regions("Mithalas Cathedral underground", "Cathedral boss right area", - self.cathedral_underground, self.cathedral_boss_r) - self.__connect_one_way_regions("Cathedral boss right area", "Mithalas Cathedral underground", - self.cathedral_boss_r, self.cathedral_underground, + _has_damaging_item(state, self.player)) + self.__connect_one_way_regions(self.cathedral_top_end, self.cathedral_underground, + lambda state: _has_energy_attack_item(state, self.player) + ) + self.__connect_one_way_regions(self.cathedral_underground, self.cathedral_boss_r) + self.__connect_one_way_regions(self.cathedral_boss_r, self.cathedral_underground, lambda state: _has_beast_form(state, self.player)) - self.__connect_one_way_regions("Cathedral boss right area", "Cathedral boss left area", - self.cathedral_boss_r, self.cathedral_boss_l, + self.__connect_one_way_regions(self.cathedral_boss_r, self.cathedral_boss_l, lambda state: _has_bind_song(state, self.player) and _has_energy_attack_item(state, self.player)) - self.__connect_one_way_regions("Cathedral boss left area", "Cathedral boss right area", - self.cathedral_boss_l, self.cathedral_boss_r) + self.__connect_one_way_regions(self.cathedral_boss_l, self.cathedral_boss_r) def __connect_forest_regions(self) -> None: """ Connect entrances of the different regions around the Kelp Forest """ - self.__connect_regions("Forest bottom right", "Veil bottom left area", - self.forest_br, self.veil_bl) - self.__connect_regions("Forest bottom right", "Forest bottom left area", - self.forest_br, self.forest_bl) - self.__connect_one_way_regions("Forest bottom left area", "Forest bottom left area, spirit crystals", - self.forest_bl, self.forest_bl_sc, + self.__connect_regions(self.forest_br, self.veil_b) + self.__connect_regions(self.forest_br, self.forest_bl) + self.__connect_one_way_regions(self.forest_bl, self.forest_bl_sc, lambda state: _has_energy_attack_item(state, self.player) or _has_fish_form(state, self.player)) - self.__connect_one_way_regions("Forest bottom left area, spirit crystals", "Forest bottom left area", - self.forest_bl_sc, self.forest_bl) - self.__connect_regions("Forest bottom right", "Forest top right area", - self.forest_br, self.forest_tr) - self.__connect_regions("Forest bottom left area", "Forest fish cave", - self.forest_bl, self.forest_fish_cave) - self.__connect_regions("Forest bottom left area", "Forest top left area", - self.forest_bl, self.forest_tl) - self.__connect_regions("Forest bottom left area", "Forest boss entrance", - self.forest_bl, self.forest_boss_entrance, + self.__connect_one_way_regions(self.forest_bl_sc, self.forest_bl) + self.__connect_regions(self.forest_br, self.forest_tr) + self.__connect_regions(self.forest_bl, self.forest_fish_cave) + self.__connect_regions(self.forest_bl, self.forest_tl) + self.__connect_regions(self.forest_bl, self.forest_boss_entrance, lambda state: _has_nature_form(state, self.player)) - self.__connect_regions("Forest top left area", "Forest top left area, fish pass", - self.forest_tl, self.forest_tl_fp, - lambda state: _has_nature_form(state, self.player) and - _has_bind_song(state, self.player) and - _has_energy_attack_item(state, self.player) and - _has_fish_form(state, self.player)) - self.__connect_regions("Forest top left area", "Forest top right area", - self.forest_tl, self.forest_tr) - self.__connect_regions("Forest top left area", "Forest boss entrance", - self.forest_tl, self.forest_boss_entrance) - self.__connect_regions("Forest boss area", "Forest boss entrance", - self.forest_boss, self.forest_boss_entrance, - lambda state: _has_energy_attack_item(state, self.player)) - self.__connect_regions("Forest top right area", "Forest top right area fish pass", - self.forest_tr, self.forest_tr_fp, + self.__connect_one_way_regions(self.forest_tl, self.forest_tl_verse_egg_room, + lambda state: _has_nature_form(state, self.player) and + _has_bind_song(state, self.player) and + _has_energy_attack_item(state, self.player) and + _has_fish_form(state, self.player)) + self.__connect_one_way_regions(self.forest_tl_verse_egg_room, self.forest_tl, + lambda state: _has_fish_form(state, self.player)) + self.__connect_regions(self.forest_tl, self.forest_tr) + self.__connect_regions(self.forest_tl, self.forest_boss_entrance) + self.__connect_one_way_regions(self.forest_boss_entrance, self.forest_boss, + lambda state: _has_energy_attack_item(state, self.player)) + self.__connect_one_way_regions(self.forest_boss, self.forest_boss_entrance) + self.__connect_regions(self.forest_tr, self.forest_tr_fp, lambda state: _has_fish_form(state, self.player)) - self.__connect_regions("Forest top right area", "Forest sprite cave", - self.forest_tr, self.forest_sprite_cave) - self.__connect_regions("Forest sprite cave", "Forest sprite cave flower tube", - self.forest_sprite_cave, self.forest_sprite_cave_tube, + self.__connect_regions(self.forest_tr, self.sprite_cave) + self.__connect_regions(self.sprite_cave, self.sprite_cave_tube, lambda state: _has_nature_form(state, self.player)) - self.__connect_regions("Forest top right area", "Mermog cave", - self.forest_tr_fp, self.mermog_cave) - self.__connect_regions("Fermog cave", "Fermog boss", - self.mermog_cave, self.mermog_boss, + self.__connect_regions(self.forest_tr_fp, self.mermog_cave) + self.__connect_regions(self.mermog_cave, self.mermog_boss, lambda state: _has_beast_form(state, self.player) and _has_energy_attack_item(state, self.player)) @@ -704,113 +687,94 @@ def __connect_veil_regions(self) -> None: """ Connect entrances of the different regions around The Veil """ - self.__connect_regions("Veil bottom left area", "Veil bottom left area, fish pass", - self.veil_bl, self.veil_bl_fp, + self.__connect_regions(self.veil_b, self.veil_b_fp, lambda state: _has_fish_form(state, self.player) and - _has_bind_song(state, self.player) and - _has_damaging_item(state, self.player)) - self.__connect_regions("Veil bottom left area", "Veil bottom area spirit crystals path", - self.veil_bl, self.veil_b_sc, + _has_bind_song(state, self.player)) + self.__connect_regions(self.veil_b, self.veil_b_sc, lambda state: _has_spirit_form(state, self.player)) - self.__connect_regions("Veil bottom area spirit crystals path", "Veil bottom right", - self.veil_b_sc, self.veil_br, + self.__connect_regions(self.veil_b_sc, self.veil_br, lambda state: _has_spirit_form(state, self.player)) - self.__connect_regions("Veil bottom right", "Veil top left area", - self.veil_br, self.veil_tl) - self.__connect_regions("Veil top left area", "Veil_top left area, fish pass", - self.veil_tl, self.veil_tl_fp, + self.__connect_regions(self.veil_br, self.veil_tl) + self.__connect_regions(self.veil_tl, self.veil_tl_fp, lambda state: _has_fish_form(state, self.player)) - self.__connect_regions("Veil top left area", "Veil right of sun temple", - self.veil_tl, self.veil_tr_r) - self.__connect_regions("Veil top left area", "Turtle cave", - self.veil_tl, self.turtle_cave) - self.__connect_regions("Turtle cave", "Turtle cave Bubble Cliff", - self.turtle_cave, self.turtle_cave_bubble) - self.__connect_regions("Veil right of sun temple", "Sun Temple right area", - self.veil_tr_r, self.sun_temple_r) - self.__connect_one_way_regions("Sun Temple right area", "Sun Temple left area", - self.sun_temple_r, self.sun_temple_l, + self.__connect_regions(self.veil_tl, self.veil_tr_r) + self.__connect_regions(self.veil_tl, self.turtle_cave) + self.__connect_regions(self.turtle_cave, self.turtle_cave_bubble) + self.__connect_regions(self.veil_tr_r, self.sun_temple_r) + + self.__connect_one_way_regions(self.sun_temple_r, self.sun_temple_l_entrance, lambda state: _has_bind_song(state, self.player) or _has_light(state, self.player)) - self.__connect_one_way_regions("Sun Temple left area", "Sun Temple right area", - self.sun_temple_l, self.sun_temple_r, + self.__connect_one_way_regions(self.sun_temple_l_entrance, self.sun_temple_r, lambda state: _has_light(state, self.player)) - self.__connect_regions("Sun Temple left area", "Veil left of sun temple", - self.sun_temple_l, self.veil_tr_l) - self.__connect_regions("Sun Temple left area", "Sun Temple before boss area", - self.sun_temple_l, self.sun_temple_boss_path) - self.__connect_regions("Sun Temple before boss area", "Sun Temple boss area", - self.sun_temple_boss_path, self.sun_temple_boss, + self.__connect_regions(self.sun_temple_l_entrance, self.veil_tr_l) + self.__connect_regions(self.sun_temple_l, self.sun_temple_l_entrance) + self.__connect_one_way_regions(self.sun_temple_l, self.sun_temple_boss_path) + self.__connect_one_way_regions(self.sun_temple_boss_path, self.sun_temple_l) + self.__connect_regions(self.sun_temple_boss_path, self.sun_temple_boss, lambda state: _has_energy_attack_item(state, self.player)) - self.__connect_one_way_regions("Sun Temple boss area", "Veil left of sun temple", - self.sun_temple_boss, self.veil_tr_l) - self.__connect_regions("Veil left of sun temple", "Octo cave top path", - self.veil_tr_l, self.octo_cave_t, - lambda state: _has_fish_form(state, self.player) and - _has_sun_form(state, self.player) and - _has_beast_form(state, self.player) and - _has_energy_attack_item(state, self.player)) - self.__connect_regions("Veil left of sun temple", "Octo cave bottom path", - self.veil_tr_l, self.octo_cave_b, + self.__connect_one_way_regions(self.sun_temple_boss, self.veil_tr_l) + self.__connect_regions(self.veil_tr_l, self.veil_tr_l_fp, lambda state: _has_fish_form(state, self.player)) + self.__connect_one_way_regions(self.veil_tr_l_fp, self.octo_cave_t, + lambda state: _has_sun_form(state, self.player) and + _has_beast_form(state, self.player) and + _has_energy_attack_item(state, self.player)) + self.__connect_one_way_regions(self.octo_cave_t, self.veil_tr_l_fp) + self.__connect_regions(self.veil_tr_l_fp, self.octo_cave_b) def __connect_abyss_regions(self) -> None: """ Connect entrances of the different regions around The Abyss """ - self.__connect_regions("Abyss left area", "Abyss bottom of left area", - self.abyss_l, self.abyss_lb, + self.__connect_regions(self.abyss_l, self.abyss_lb, lambda state: _has_nature_form(state, self.player)) - self.__connect_regions("Abyss left bottom area", "Sunken City right area", - self.abyss_lb, self.sunken_city_r, + self.__connect_regions(self.abyss_lb, self.sunken_city_r, lambda state: _has_li(state, self.player)) - self.__connect_one_way_regions("Abyss left bottom area", "Body center area", - self.abyss_lb, self.body_c, + self.__connect_one_way_regions(self.abyss_lb, self.body_c, lambda state: _has_tongue_cleared(state, self.player)) - self.__connect_one_way_regions("Body center area", "Abyss left bottom area", - self.body_c, self.abyss_lb) - self.__connect_regions("Abyss left area", "King jellyfish cave", - self.abyss_l, self.king_jellyfish_cave, - lambda state: (_has_energy_form(state, self.player) and - _has_beast_form(state, self.player)) or - _has_dual_form(state, self.player)) - self.__connect_regions("Abyss left area", "Abyss right area", - self.abyss_l, self.abyss_r) - self.__connect_regions("Abyss right area", "Abyss right area, transturtle", - self.abyss_r, self.abyss_r_transturtle) - self.__connect_regions("Abyss right area", "Inside the whale", - self.abyss_r, self.whale, + self.__connect_one_way_regions(self.body_c, self.abyss_lb) + self.__connect_one_way_regions(self.abyss_l, self.king_jellyfish_cave, + lambda state: _has_dual_form(state, self.player) or + (_has_energy_form(state, self.player) and + _has_beast_form(state, self.player))) + self.__connect_one_way_regions(self.king_jellyfish_cave, self.abyss_l) + self.__connect_regions(self.abyss_l, self.abyss_r) + self.__connect_regions(self.abyss_r, self.abyss_r_whale, lambda state: _has_spirit_form(state, self.player) and _has_sun_form(state, self.player)) - self.__connect_regions("Abyss right area", "First secret area", - self.abyss_r, self.first_secret, + self.__connect_regions(self.abyss_r_whale, self.whale) + self.__connect_regions(self.abyss_r, self.abyss_r_transturtle) + self.__connect_regions(self.abyss_r, self.first_secret, lambda state: _has_spirit_form(state, self.player) and _has_sun_form(state, self.player) and _has_bind_song(state, self.player) and _has_energy_attack_item(state, self.player)) - self.__connect_regions("Abyss right area", "Ice Cave", - self.abyss_r, self.ice_cave, + self.__connect_regions(self.abyss_r, self.ice_cave, lambda state: _has_spirit_form(state, self.player)) - self.__connect_regions("Ice cave", "Bubble Cave", - self.ice_cave, self.bubble_cave, - lambda state: _has_beast_form(state, self.player) or - _has_hot_soup(state, self.player)) - self.__connect_regions("Bubble Cave boss area", "Bubble Cave", - self.bubble_cave, self.bubble_cave_boss, - lambda state: _has_nature_form(state, self.player) and _has_bind_song(state, self.player) - ) + self.__connect_regions(self.ice_cave, self.frozen_feil) + self.__connect_one_way_regions(self.frozen_feil, self.bubble_cave, + lambda state: _has_beast_form(state, self.player) or + _has_hot_soup(state, self.player)) + self.__connect_one_way_regions(self.bubble_cave, self.frozen_feil) + self.__connect_one_way_regions(self.bubble_cave, self.bubble_cave_boss, + lambda state: _has_nature_form(state, self.player) and + _has_bind_song(state, self.player) + ) + self.__connect_one_way_regions(self.bubble_cave_boss, self.bubble_cave) def __connect_sunken_city_regions(self) -> None: """ Connect entrances of the different regions around The Sunken City """ - self.__connect_regions("Sunken City right area", "Sunken City left area", - self.sunken_city_r, self.sunken_city_l) - self.__connect_regions("Sunken City left area", "Sunken City bedroom", - self.sunken_city_l, self.sunken_city_l_bedroom, + self.__connect_regions(self.sunken_city_r, self.sunken_city_l) + self.__connect_one_way_regions(self.sunken_city_r, self.sunken_city_r_crates, + lambda state: _has_energy_attack_item(state, self.player)) + self.__connect_regions(self.sunken_city_l, self.sunken_city_l_bedroom, lambda state: _has_spirit_form(state, self.player)) - self.__connect_regions("Sunken City left area", "Sunken City boss area", - self.sunken_city_l, self.sunken_city_boss, + self.__connect_one_way_regions(self.sunken_city_l, self.sunken_city_l_crates, + lambda state: _has_energy_attack_item(state, self.player)) + self.__connect_regions(self.sunken_city_l, self.sunken_city_boss, lambda state: _has_beast_form(state, self.player) and _has_sun_form(state, self.player) and _has_energy_attack_item(state, self.player) and @@ -820,62 +784,55 @@ def __connect_body_regions(self) -> None: """ Connect entrances of the different regions around The Body """ - self.__connect_regions("Body center area", "Body left area", - self.body_c, self.body_l, - lambda state: _has_energy_form(state, self.player)) - self.__connect_regions("Body center area", "Body right area top path", - self.body_c, self.body_rt) - self.__connect_regions("Body center area", "Body right area bottom path", - self.body_c, self.body_rb, - lambda state: _has_energy_form(state, self.player)) - self.__connect_regions("Body center area", "Body bottom area", - self.body_c, self.body_b, + self.__connect_one_way_regions(self.body_c, self.body_l, + lambda state: _has_energy_form(state, self.player)) + self.__connect_one_way_regions(self.body_l, self.body_c) + self.__connect_regions(self.body_c, self.body_rt) + self.__connect_one_way_regions(self.body_c, self.body_rb, + lambda state: _has_energy_form(state, self.player)) + self.__connect_one_way_regions(self.body_rb, self.body_c) + self.__connect_regions(self.body_c, self.body_b, lambda state: _has_dual_form(state, self.player)) - self.__connect_regions("Body bottom area", "Final Boss area", - self.body_b, self.final_boss_loby, + self.__connect_regions(self.body_b, self.final_boss_loby, lambda state: _has_dual_form(state, self.player)) - self.__connect_regions("Before Final Boss", "Final Boss tube", - self.final_boss_loby, self.final_boss_tube, + self.__connect_regions(self.final_boss_loby, self.final_boss_tube, lambda state: _has_nature_form(state, self.player)) - self.__connect_one_way_regions("Before Final Boss", "Final Boss", - self.final_boss_loby, self.final_boss, + self.__connect_one_way_regions(self.final_boss_loby, self.final_boss, lambda state: _has_energy_form(state, self.player) and _has_dual_form(state, self.player) and _has_sun_form(state, self.player) and _has_bind_song(state, self.player)) - self.__connect_one_way_regions("final boss third form area", "final boss end", - self.final_boss, self.final_boss_end) + self.__connect_one_way_regions(self.final_boss, self.final_boss_end) - def __connect_transturtle(self, item_source: str, item_target: str, region_source: Region, - region_target: Region) -> None: + def __connect_transturtle(self, item_target: str, region_source: Region, region_target: Region) -> None: """Connect a single transturtle to another one""" - if item_source != item_target: - self.__connect_one_way_regions(item_source, item_target, region_source, region_target, + if region_source != region_target: + self.__connect_one_way_regions(region_source, region_target, lambda state: state.has(item_target, self.player)) - def _connect_transturtle_to_other(self, item: str, region: Region) -> None: + def _connect_transturtle_to_other(self, region: Region) -> None: """Connect a single transturtle to all others""" - self.__connect_transturtle(item, "Transturtle Veil top left", region, self.veil_tl) - self.__connect_transturtle(item, "Transturtle Veil top right", region, self.veil_tr_l) - self.__connect_transturtle(item, "Transturtle Open Water top right", region, self.openwater_tr_turtle) - self.__connect_transturtle(item, "Transturtle Forest bottom left", region, self.forest_bl) - self.__connect_transturtle(item, "Transturtle Home Water", region, self.home_water_transturtle) - self.__connect_transturtle(item, "Transturtle Abyss right", region, self.abyss_r_transturtle) - self.__connect_transturtle(item, "Transturtle Final Boss", region, self.final_boss_tube) - self.__connect_transturtle(item, "Transturtle Simon Says", region, self.simon) - self.__connect_transturtle(item, "Transturtle Arnassi Ruins", region, self.arnassi_cave_transturtle) + self.__connect_transturtle(ItemNames.TRANSTURTLE_VEIL_TOP_LEFT, region, self.veil_tl) + self.__connect_transturtle(ItemNames.TRANSTURTLE_VEIL_TOP_RIGHT, region, self.veil_tr_l) + self.__connect_transturtle(ItemNames.TRANSTURTLE_OPEN_WATERS, region, self.openwater_tr_turtle) + self.__connect_transturtle(ItemNames.TRANSTURTLE_KELP_FOREST, region, self.forest_bl) + self.__connect_transturtle(ItemNames.TRANSTURTLE_HOME_WATERS, region, self.home_water_transturtle) + self.__connect_transturtle(ItemNames.TRANSTURTLE_ABYSS, region, self.abyss_r_transturtle) + self.__connect_transturtle(ItemNames.TRANSTURTLE_BODY, region, self.final_boss_tube) + self.__connect_transturtle(ItemNames.TRANSTURTLE_SIMON_SAYS, region, self.simon) + self.__connect_transturtle(ItemNames.TRANSTURTLE_ARNASSI_RUINS, region, self.arnassi_cave_transturtle) def __connect_transturtles(self) -> None: """Connect every transturtle with others""" - self._connect_transturtle_to_other("Transturtle Veil top left", self.veil_tl) - self._connect_transturtle_to_other("Transturtle Veil top right", self.veil_tr_l) - self._connect_transturtle_to_other("Transturtle Open Water top right", self.openwater_tr_turtle) - self._connect_transturtle_to_other("Transturtle Forest bottom left", self.forest_bl) - self._connect_transturtle_to_other("Transturtle Home Water", self.home_water_transturtle) - self._connect_transturtle_to_other("Transturtle Abyss right", self.abyss_r_transturtle) - self._connect_transturtle_to_other("Transturtle Final Boss", self.final_boss_tube) - self._connect_transturtle_to_other("Transturtle Simon Says", self.simon) - self._connect_transturtle_to_other("Transturtle Arnassi Ruins", self.arnassi_cave_transturtle) + self._connect_transturtle_to_other(self.veil_tl) + self._connect_transturtle_to_other(self.veil_tr_l) + self._connect_transturtle_to_other(self.openwater_tr_turtle) + self._connect_transturtle_to_other(self.forest_bl) + self._connect_transturtle_to_other(self.home_water_transturtle) + self._connect_transturtle_to_other(self.abyss_r_transturtle) + self._connect_transturtle_to_other(self.final_boss_tube) + self._connect_transturtle_to_other(self.simon) + self._connect_transturtle_to_other(self.arnassi_cave_transturtle) def connect_regions(self) -> None: """ @@ -910,20 +867,20 @@ def __add_event_big_bosses(self) -> None: Add every bit bosses (other than the creator) events to the `world` """ self.__add_event_location(self.energy_temple_boss, - "Beating Fallen God", - "Fallen God beated") + AquariaLocationNames.BEATING_FALLEN_GOD, + ItemNames.FALLEN_GOD_BEATED) self.__add_event_location(self.cathedral_boss_l, - "Beating Mithalan God", - "Mithalan God beated") + AquariaLocationNames.BEATING_MITHALAN_GOD, + ItemNames.MITHALAN_GOD_BEATED) self.__add_event_location(self.forest_boss, - "Beating Drunian God", - "Drunian God beated") + AquariaLocationNames.BEATING_DRUNIAN_GOD, + ItemNames.DRUNIAN_GOD_BEATED) self.__add_event_location(self.sun_temple_boss, - "Beating Sun God", - "Sun God beated") + AquariaLocationNames.BEATING_LUMEREAN_GOD, + ItemNames.LUMEREAN_GOD_BEATED) self.__add_event_location(self.sunken_city_boss, - "Beating the Golem", - "The Golem beated") + AquariaLocationNames.BEATING_THE_GOLEM, + ItemNames.THE_GOLEM_BEATED) def __add_event_mini_bosses(self) -> None: """ @@ -931,43 +888,44 @@ def __add_event_mini_bosses(self) -> None: events to the `world` """ self.__add_event_location(self.home_water_nautilus, - "Beating Nautilus Prime", - "Nautilus Prime beated") + AquariaLocationNames.BEATING_NAUTILUS_PRIME, + ItemNames.NAUTILUS_PRIME_BEATED) self.__add_event_location(self.energy_temple_blaster_room, - "Beating Blaster Peg Prime", - "Blaster Peg Prime beated") + AquariaLocationNames.BEATING_BLASTER_PEG_PRIME, + ItemNames.BLASTER_PEG_PRIME_BEATED) self.__add_event_location(self.mermog_boss, - "Beating Mergog", - "Mergog beated") - self.__add_event_location(self.cathedral_l_tube, - "Beating Mithalan priests", - "Mithalan priests beated") + AquariaLocationNames.BEATING_MERGOG, + ItemNames.MERGOG_BEATED) + self.__add_event_location(self.mithalas_castle_tube, + AquariaLocationNames.BEATING_MITHALAN_PRIESTS, + ItemNames.MITHALAN_PRIESTS_BEATED) self.__add_event_location(self.octo_cave_t, - "Beating Octopus Prime", - "Octopus Prime beated") + AquariaLocationNames.BEATING_OCTOPUS_PRIME, + ItemNames.OCTOPUS_PRIME_BEATED) self.__add_event_location(self.arnassi_crab_boss, - "Beating Crabbius Maximus", - "Crabbius Maximus beated") + AquariaLocationNames.BEATING_CRABBIUS_MAXIMUS, + ItemNames.CRABBIUS_MAXIMUS_BEATED) self.__add_event_location(self.bubble_cave_boss, - "Beating Mantis Shrimp Prime", - "Mantis Shrimp Prime beated") + AquariaLocationNames.BEATING_MANTIS_SHRIMP_PRIME, + ItemNames.MANTIS_SHRIMP_PRIME_BEATED) self.__add_event_location(self.king_jellyfish_cave, - "Beating King Jellyfish God Prime", - "King Jellyfish God Prime beated") + AquariaLocationNames.BEATING_KING_JELLYFISH_GOD_PRIME, + ItemNames.KING_JELLYFISH_GOD_PRIME_BEATED) def __add_event_secrets(self) -> None: """ Add secrets events to the `world` """ - self.__add_event_location(self.first_secret, # Doit ajouter une région pour le "first secret" - "First secret", - "First secret obtained") + self.__add_event_location(self.first_secret, + # Doit ajouter une région pour le AquariaLocationNames.FIRST_SECRET + AquariaLocationNames.FIRST_SECRET, + ItemNames.FIRST_SECRET_OBTAINED) self.__add_event_location(self.mithalas_city, - "Second secret", - "Second secret obtained") + AquariaLocationNames.SECOND_SECRET, + ItemNames.SECOND_SECRET_OBTAINED) self.__add_event_location(self.sun_temple_l, - "Third secret", - "Third secret obtained") + AquariaLocationNames.THIRD_SECRET, + ItemNames.THIRD_SECRET_OBTAINED) def add_event_locations(self) -> None: """ @@ -977,287 +935,236 @@ def add_event_locations(self) -> None: self.__add_event_big_bosses() self.__add_event_secrets() self.__add_event_location(self.sunken_city_boss, - "Sunken City cleared", - "Body tongue cleared") + AquariaLocationNames.SUNKEN_CITY_CLEARED, + ItemNames.BODY_TONGUE_CLEARED) self.__add_event_location(self.sun_temple_r, - "Sun Crystal", - "Has sun crystal") - self.__add_event_location(self.final_boss_end, "Objective complete", - "Victory") - - def __adjusting_urns_rules(self) -> None: - """Since Urns need to be broken, add a damaging item to rules""" - add_rule(self.multiworld.get_location("Open Water top right area, first urn in the Mithalas exit", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule( - self.multiworld.get_location("Open Water top right area, second urn in the Mithalas exit", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Open Water top right area, third urn in the Mithalas exit", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City, first urn in one of the homes", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City, second urn in one of the homes", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City, first urn in the city reserve", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City, second urn in the city reserve", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City, third urn in the city reserve", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City, urn in the Castle flower tube entrance", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City Castle, urn in the bedroom", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City Castle, first urn of the single lamp path", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City Castle, second urn of the single lamp path", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City Castle, urn in the bottom room", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City Castle, first urn on the entrance path", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City Castle, second urn on the entrance path", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City, urn inside a home fish pass", self.player), - lambda state: _has_damaging_item(state, self.player)) - - def __adjusting_crates_rules(self) -> None: - """Since Crate need to be broken, add a damaging item to rules""" - add_rule(self.multiworld.get_location("Sunken City right area, crate close to the save crystal", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Sunken City right area, crate in the left bottom room", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Sunken City left area, crate in the little pipe room", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Sunken City left area, crate close to the save crystal", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Sunken City left area, crate before the bedroom", self.player), - lambda state: _has_damaging_item(state, self.player)) + AquariaLocationNames.SUN_CRYSTAL, + ItemNames.HAS_SUN_CRYSTAL) + self.__add_event_location(self.final_boss_end, AquariaLocationNames.OBJECTIVE_COMPLETE, + ItemNames.VICTORY) def __adjusting_soup_rules(self) -> None: """ Modify rules for location that need soup """ - add_rule(self.multiworld.get_location("Turtle cave, Urchin Costume", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.TURTLE_CAVE_URCHIN_COSTUME, self.player), lambda state: _has_hot_soup(state, self.player)) - add_rule(self.multiworld.get_location("The Veil top right area, bulb at the top of the waterfall", self.player), - lambda state: _has_beast_and_soup_form(state, self.player)) + add_rule(self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB, self.player), + lambda state: _has_beast_and_soup_form(state, self.player) or + state.has(ItemNames.LUMEREAN_GOD_BEATED, self.player), combine="or") + add_rule(self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB, self.player), + lambda state: _has_beast_and_soup_form(state, self.player) or + state.has(ItemNames.LUMEREAN_GOD_BEATED, self.player), combine="or") + add_rule( + self.multiworld.get_location(AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL, + self.player), + lambda state: _has_beast_and_soup_form(state, self.player)) def __adjusting_under_rock_location(self) -> None: """ Modify rules implying bind song needed for bulb under rocks """ - add_rule(self.multiworld.get_location("Home Water, bulb under the rock in the left path from the Verse Cave", - self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Verse Cave left area, bulb under the rock at the end of the path", - self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Naija's Home, bulb under the rock at the right of the main path", - self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Song Cave, bulb under the rock in the path to the singing statues", - self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Song Cave, bulb under the rock close to the song door", - self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Energy Temple second area, bulb under the rock", - self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Open Water top left area, bulb under the rock in the right path", - self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Open Water top left area, bulb under the rock in the left path", - self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Kelp Forest top right area, bulb under the rock in the right path", - self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("The Veil top left area, bulb under the rock in the top right path", - self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Abyss right area, bulb behind the rock in the whale room", + add_rule(self.multiworld.get_location( + AquariaLocationNames.HOME_WATERS_BULB_UNDER_THE_ROCK_IN_THE_LEFT_PATH_FROM_THE_VERSE_CAVE, + self.player), lambda state: _has_bind_song(state, self.player)) + add_rule(self.multiworld.get_location( + AquariaLocationNames.VERSE_CAVE_LEFT_AREA_BULB_UNDER_THE_ROCK_AT_THE_END_OF_THE_PATH, + self.player), lambda state: _has_bind_song(state, self.player)) + add_rule(self.multiworld.get_location( + AquariaLocationNames.NAIJA_S_HOME_BULB_UNDER_THE_ROCK_AT_THE_RIGHT_OF_THE_MAIN_PATH, + self.player), lambda state: _has_bind_song(state, self.player)) + add_rule(self.multiworld.get_location( + AquariaLocationNames.SONG_CAVE_BULB_UNDER_THE_ROCK_IN_THE_PATH_TO_THE_SINGING_STATUES, + self.player), lambda state: _has_bind_song(state, self.player)) + add_rule(self.multiworld.get_location(AquariaLocationNames.SONG_CAVE_BULB_UNDER_THE_ROCK_CLOSE_TO_THE_SONG_DOOR, self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Abyss right area, bulb in the middle path", + add_rule(self.multiworld.get_location(AquariaLocationNames.ENERGY_TEMPLE_SECOND_AREA_BULB_UNDER_THE_ROCK, self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("The Veil top left area, bulb under the rock in the top right path", + add_rule(self.multiworld.get_location( + AquariaLocationNames.OPEN_WATERS_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH, + self.player), lambda state: _has_bind_song(state, self.player)) + add_rule(self.multiworld.get_location( + AquariaLocationNames.OPEN_WATERS_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_LEFT_PATH, + self.player), lambda state: _has_bind_song(state, self.player)) + add_rule(self.multiworld.get_location( + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH, + self.player), lambda state: _has_bind_song(state, self.player)) + add_rule(self.multiworld.get_location( + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_TOP_RIGHT_PATH, + self.player), lambda state: _has_bind_song(state, self.player)) + add_rule( + self.multiworld.get_location(AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_WHALE_ROOM, + self.player), lambda state: _has_bind_song(state, self.player)) + add_rule(self.multiworld.get_location(AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_IN_THE_MIDDLE_PATH, self.player), lambda state: _has_bind_song(state, self.player)) + add_rule(self.multiworld.get_location( + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_TOP_RIGHT_PATH, + self.player), lambda state: _has_bind_song(state, self.player)) def __adjusting_light_in_dark_place_rules(self) -> None: - add_rule(self.multiworld.get_location("Kelp Forest top right area, Black Pearl", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BLACK_PEARL, self.player), lambda state: _has_light(state, self.player)) - add_rule(self.multiworld.get_location("Kelp Forest bottom right area, Odd Container", self.player), - lambda state: _has_light(state, self.player)) - add_rule(self.multiworld.get_entrance("Body center area to Abyss left bottom area", self.player), - lambda state: _has_light(state, self.player)) - add_rule(self.multiworld.get_entrance("Veil left of sun temple to Octo cave top path", self.player), + add_rule( + self.multiworld.get_location(AquariaLocationNames.KELP_FOREST_BOTTOM_RIGHT_AREA_ODD_CONTAINER, self.player), + lambda state: _has_light(state, self.player)) + add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.sun_temple_l_entrance, self.sun_temple_l), + self.player), lambda state: _has_light(state, self.player) or + _has_sun_crystal(state, self.player)) + add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.sun_temple_boss_path, self.sun_temple_l), + self.player), lambda state: _has_light(state, self.player) or + _has_sun_crystal(state, self.player)) + add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.abyss_r_transturtle, self.abyss_r), + self.player), lambda state: _has_light(state, self.player)) - add_rule(self.multiworld.get_entrance("Open Water bottom right area to Abyss right area", self.player), + add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.body_c, self.abyss_lb), self.player), lambda state: _has_light(state, self.player)) - add_rule(self.multiworld.get_entrance("Open Water bottom left area to Abyss left area", self.player), + add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.openwater_br, self.abyss_r), self.player), lambda state: _has_light(state, self.player)) - add_rule(self.multiworld.get_entrance("Veil left of sun temple to Sun Temple left area", self.player), - lambda state: _has_light(state, self.player) or _has_sun_crystal(state, self.player)) - add_rule(self.multiworld.get_entrance("Abyss right area, transturtle to Abyss right area", self.player), + add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.openwater_bl, self.abyss_l), self.player), lambda state: _has_light(state, self.player)) def __adjusting_manual_rules(self) -> None: - add_rule(self.multiworld.get_location("Mithalas Cathedral, Mithalan Dress", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.MITHALAS_CATHEDRAL_MITHALAN_DRESS, self.player), lambda state: _has_beast_form(state, self.player)) - add_rule( - self.multiworld.get_location("Open Water bottom left area, bulb inside the lowest fish pass", self.player), + add_rule(self.multiworld.get_location( + AquariaLocationNames.OPEN_WATERS_BOTTOM_LEFT_AREA_BULB_INSIDE_THE_LOWEST_FISH_PASS, self.player), lambda state: _has_fish_form(state, self.player)) - add_rule(self.multiworld.get_location("Kelp Forest bottom left area, Walker Baby", self.player), - lambda state: _has_spirit_form(state, self.player)) add_rule( - self.multiworld.get_location("The Veil top left area, bulb hidden behind the blocking rock", self.player), + self.multiworld.get_location(AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_WALKER_BABY, self.player), + lambda state: _has_spirit_form(state, self.player)) + add_rule( + self.multiworld.get_location( + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_HIDDEN_BEHIND_THE_BLOCKING_ROCK, self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Turtle cave, Turtle Egg", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.TURTLE_CAVE_TURTLE_EGG, self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Abyss left area, bulb in the bottom fish pass", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_THE_BOTTOM_FISH_PASS, + self.player), lambda state: _has_fish_form(state, self.player)) - add_rule(self.multiworld.get_location("Song Cave, Anemone Seed", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.SONG_CAVE_ANEMONE_SEED, self.player), lambda state: _has_nature_form(state, self.player)) - add_rule(self.multiworld.get_location("Song Cave, Verse Egg", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.SONG_CAVE_VERSE_EGG, self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Verse Cave right area, Big Seed", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.VERSE_CAVE_RIGHT_AREA_BIG_SEED, self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Arnassi Ruins, Song Plant Spore", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.ARNASSI_RUINS_SONG_PLANT_SPORE, self.player), lambda state: _has_beast_form_or_arnassi_armor(state, self.player)) - add_rule(self.multiworld.get_location("Energy Temple first area, bulb in the bottom room blocked by a rock", - self.player), lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Home Water, bulb in the bottom left room", self.player), - lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Home Water, bulb in the path below Nautilus Prime", self.player), - lambda state: _has_bind_song(state, self.player)) - add_rule(self.multiworld.get_location("Naija's Home, bulb after the energy door", self.player), - lambda state: _has_energy_attack_item(state, self.player)) - add_rule(self.multiworld.get_location("Abyss right area, bulb behind the rock in the whale room", self.player), - lambda state: _has_spirit_form(state, self.player) and - _has_sun_form(state, self.player)) - add_rule(self.multiworld.get_location("Arnassi Ruins, Arnassi Armor", self.player), + add_rule(self.multiworld.get_location( + AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BULB_IN_THE_BOTTOM_ROOM_BLOCKED_BY_A_ROCK, + self.player), lambda state: _has_bind_song(state, self.player)) + add_rule( + self.multiworld.get_location(AquariaLocationNames.NAIJA_S_HOME_BULB_AFTER_THE_ENERGY_DOOR, self.player), + lambda state: _has_energy_attack_item(state, self.player)) + add_rule(self.multiworld.get_location(AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR, self.player), lambda state: _has_fish_form(state, self.player) or _has_beast_and_soup_form(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City, urn inside a home fish pass", self.player), - lambda state: _has_damaging_item(state, self.player)) - add_rule(self.multiworld.get_location("Mithalas City, urn in the Castle flower tube entrance", self.player), + add_rule( + self.multiworld.get_location(AquariaLocationNames.MITHALAS_CITY_URN_INSIDE_A_HOME_FISH_PASS, self.player), + lambda state: _has_damaging_item(state, self.player)) + add_rule(self.multiworld.get_location(AquariaLocationNames.MITHALAS_CITY_URN_IN_THE_CASTLE_FLOWER_TUBE_ENTRANCE, + self.player), lambda state: _has_damaging_item(state, self.player)) add_rule(self.multiworld.get_location( - "The Veil top right area, bulb in the middle of the wall jump cliff", self.player + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_IN_THE_MIDDLE_OF_THE_WALL_JUMP_CLIFF, self.player ), lambda state: _has_beast_form_or_arnassi_armor(state, self.player)) - add_rule(self.multiworld.get_location("Kelp Forest top left area, Jelly Egg", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_JELLY_EGG, self.player), lambda state: _has_beast_form(state, self.player)) - add_rule(self.multiworld.get_location("Sun Worm path, first cliff bulb", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB, self.player), lambda state: state.has("Sun God beated", self.player)) - add_rule(self.multiworld.get_location("Sun Worm path, second cliff bulb", self.player), + add_rule(self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB, self.player), lambda state: state.has("Sun God beated", self.player)) - add_rule(self.multiworld.get_location("The Body center area, breaking Li's cage", self.player), - lambda state: _has_tongue_cleared(state, self.player)) + add_rule( + self.multiworld.get_location(AquariaLocationNames.THE_BODY_CENTER_AREA_BREAKING_LI_S_CAGE, self.player), + lambda state: _has_tongue_cleared(state, self.player)) add_rule(self.multiworld.get_location( - "Open Water top right area, bulb in the small path before Mithalas", - self.player), lambda state: _has_bind_song(state, self.player) + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_SMALL_PATH_BEFORE_MITHALAS, + self.player), lambda state: _has_bind_song(state, self.player) ) def __no_progression_hard_or_hidden_location(self) -> None: - self.multiworld.get_location("Energy Temple boss area, Fallen God Tooth", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Mithalas boss area, beating Mithalan God", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Kelp Forest boss area, beating Drunian God", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Sun Temple boss area, beating Sun God", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Sunken City, bulb on top of the boss area", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Home Water, Nautilus Egg", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Energy Temple blaster room, Blaster Egg", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Mithalas City Castle, beating the Priests", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Mermog cave, Piranha Egg", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Octopus Cave, Dumbo Egg", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("King Jellyfish Cave, bulb in the right path from King Jelly", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("King Jellyfish Cave, Jellyfish Costume", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Final Boss area, bulb in the boss third form room", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Sun Worm path, first cliff bulb", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Sun Worm path, second cliff bulb", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("The Veil top right area, bulb at the top of the waterfall", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Bubble Cave, bulb in the left cave wall", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Bubble Cave, bulb in the right cave wall (behind the ice crystal)", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Bubble Cave, Verse Egg", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Kelp Forest bottom left area, bulb close to the spirit crystals", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Kelp Forest bottom left area, Walker Baby", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Sun Temple, Sun Key", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("The Body bottom area, Mutant Costume", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Sun Temple, bulb in the hidden room of the right part", - self.player).item_rule = \ - lambda item: not item.advancement - self.multiworld.get_location("Arnassi Ruins, Arnassi Armor", - self.player).item_rule = \ - lambda item: not item.advancement + self.multiworld.get_location(AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.MITHALAS_BOSS_AREA_BEATING_MITHALAN_GOD, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.KELP_FOREST_BOSS_AREA_BEATING_DRUNIAN_GOD, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_AREA_BEATING_LUMEREAN_GOD, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.HOME_WATERS_NAUTILUS_EGG, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.MITHALAS_CITY_CASTLE_BEATING_THE_PRIESTS, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.KING_JELLYFISH_CAVE_BULB_IN_THE_RIGHT_PATH_FROM_KING_JELLY, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.KING_JELLYFISH_CAVE_JELLYFISH_COSTUME, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location( + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location( + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_BULB_CLOSE_TO_THE_SPIRIT_CRYSTALS, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_WALKER_BABY, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_SUN_KEY, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.THE_BODY_BOTTOM_AREA_MUTANT_COSTUME, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_HIDDEN_ROOM_OF_THE_RIGHT_PART, + self.player).item_rule = _item_not_advancement + self.multiworld.get_location(AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR, + self.player).item_rule = _item_not_advancement def adjusting_rules(self, options: AquariaOptions) -> None: """ Modify rules for single location or optional rules """ - self.multiworld.get_entrance("Before Final Boss to Final Boss", self.player) - self.__adjusting_urns_rules() - self.__adjusting_crates_rules() - self.__adjusting_soup_rules() self.__adjusting_manual_rules() + self.__adjusting_soup_rules() if options.light_needed_to_get_to_dark_places: self.__adjusting_light_in_dark_place_rules() if options.bind_song_needed_to_get_under_rock_bulb: self.__adjusting_under_rock_location() if options.mini_bosses_to_beat.value > 0: - add_rule(self.multiworld.get_entrance("Before Final Boss to Final Boss", self.player), - lambda state: _has_mini_bosses(state, self.player)) + add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.final_boss_loby, self.final_boss), + self.player), lambda state: _has_mini_bosses(state, self.player)) if options.big_bosses_to_beat.value > 0: - add_rule(self.multiworld.get_entrance("Before Final Boss to Final Boss", self.player), - lambda state: _has_big_bosses(state, self.player)) - if options.objective.value == 1: - add_rule(self.multiworld.get_entrance("Before Final Boss to Final Boss", self.player), - lambda state: _has_secrets(state, self.player)) - if options.unconfine_home_water.value in [0, 1]: - add_rule(self.multiworld.get_entrance("Home Water to Home Water transturtle room", self.player), - lambda state: _has_bind_song(state, self.player)) - if options.unconfine_home_water.value in [0, 2]: - add_rule(self.multiworld.get_entrance("Home Water to Open Water top left area", self.player), - lambda state: _has_bind_song(state, self.player) and _has_energy_attack_item(state, self.player)) - if options.early_energy_form: - self.multiworld.early_items[self.player]["Energy form"] = 1 - + add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.final_boss_loby, self.final_boss), + self.player), lambda state: _has_big_bosses(state, self.player)) + if options.objective.value == options.objective.option_obtain_secrets_and_kill_the_creator: + add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.final_boss_loby, self.final_boss), + self.player), lambda state: _has_secrets(state, self.player)) + if (options.unconfine_home_water.value == UnconfineHomeWater.option_via_energy_door or + options.unconfine_home_water.value == UnconfineHomeWater.option_off): + add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.home_water, self.home_water_transturtle), + self.player), lambda state: _has_bind_song(state, self.player)) + if (options.unconfine_home_water.value == UnconfineHomeWater.option_via_transturtle or + options.unconfine_home_water.value == UnconfineHomeWater.option_off): + add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.home_water, self.openwater_tl), + self.player), + lambda state: _has_bind_song(state, self.player) and + _has_energy_attack_item(state, self.player)) if options.no_progression_hard_or_hidden_locations: self.__no_progression_hard_or_hidden_location() @@ -1292,9 +1199,9 @@ def __add_open_water_regions_to_world(self) -> None: self.multiworld.regions.append(self.skeleton_path) self.multiworld.regions.append(self.skeleton_path_sc) self.multiworld.regions.append(self.arnassi) - self.multiworld.regions.append(self.arnassi_path) - self.multiworld.regions.append(self.arnassi_crab_boss) + self.multiworld.regions.append(self.arnassi_cave) self.multiworld.regions.append(self.arnassi_cave_transturtle) + self.multiworld.regions.append(self.arnassi_crab_boss) self.multiworld.regions.append(self.simon) def __add_mithalas_regions_to_world(self) -> None: @@ -1304,10 +1211,12 @@ def __add_mithalas_regions_to_world(self) -> None: self.multiworld.regions.append(self.mithalas_city) self.multiworld.regions.append(self.mithalas_city_top_path) self.multiworld.regions.append(self.mithalas_city_fishpass) - self.multiworld.regions.append(self.cathedral_l) - self.multiworld.regions.append(self.cathedral_l_tube) - self.multiworld.regions.append(self.cathedral_l_sc) - self.multiworld.regions.append(self.cathedral_r) + self.multiworld.regions.append(self.mithalas_castle) + self.multiworld.regions.append(self.mithalas_castle_tube) + self.multiworld.regions.append(self.mithalas_castle_sc) + self.multiworld.regions.append(self.cathedral_top_start) + self.multiworld.regions.append(self.cathedral_top_start_urns) + self.multiworld.regions.append(self.cathedral_top_end) self.multiworld.regions.append(self.cathedral_underground) self.multiworld.regions.append(self.cathedral_boss_l) self.multiworld.regions.append(self.cathedral_boss_r) @@ -1317,7 +1226,7 @@ def __add_forest_regions_to_world(self) -> None: Add every region around the kelp forest to the `world` """ self.multiworld.regions.append(self.forest_tl) - self.multiworld.regions.append(self.forest_tl_fp) + self.multiworld.regions.append(self.forest_tl_verse_egg_room) self.multiworld.regions.append(self.forest_tr) self.multiworld.regions.append(self.forest_tr_fp) self.multiworld.regions.append(self.forest_bl) @@ -1325,8 +1234,8 @@ def __add_forest_regions_to_world(self) -> None: self.multiworld.regions.append(self.forest_br) self.multiworld.regions.append(self.forest_boss) self.multiworld.regions.append(self.forest_boss_entrance) - self.multiworld.regions.append(self.forest_sprite_cave) - self.multiworld.regions.append(self.forest_sprite_cave_tube) + self.multiworld.regions.append(self.sprite_cave) + self.multiworld.regions.append(self.sprite_cave_tube) self.multiworld.regions.append(self.mermog_cave) self.multiworld.regions.append(self.mermog_boss) self.multiworld.regions.append(self.forest_fish_cave) @@ -1338,16 +1247,18 @@ def __add_veil_regions_to_world(self) -> None: self.multiworld.regions.append(self.veil_tl) self.multiworld.regions.append(self.veil_tl_fp) self.multiworld.regions.append(self.veil_tr_l) + self.multiworld.regions.append(self.veil_tr_l_fp) self.multiworld.regions.append(self.veil_tr_r) - self.multiworld.regions.append(self.veil_bl) + self.multiworld.regions.append(self.veil_b) self.multiworld.regions.append(self.veil_b_sc) - self.multiworld.regions.append(self.veil_bl_fp) + self.multiworld.regions.append(self.veil_b_fp) self.multiworld.regions.append(self.veil_br) self.multiworld.regions.append(self.octo_cave_t) self.multiworld.regions.append(self.octo_cave_b) self.multiworld.regions.append(self.turtle_cave) self.multiworld.regions.append(self.turtle_cave_bubble) self.multiworld.regions.append(self.sun_temple_l) + self.multiworld.regions.append(self.sun_temple_l_entrance) self.multiworld.regions.append(self.sun_temple_r) self.multiworld.regions.append(self.sun_temple_boss_path) self.multiworld.regions.append(self.sun_temple_boss) @@ -1359,6 +1270,7 @@ def __add_abyss_regions_to_world(self) -> None: self.multiworld.regions.append(self.abyss_l) self.multiworld.regions.append(self.abyss_lb) self.multiworld.regions.append(self.abyss_r) + self.multiworld.regions.append(self.abyss_r_whale) self.multiworld.regions.append(self.abyss_r_transturtle) self.multiworld.regions.append(self.ice_cave) self.multiworld.regions.append(self.bubble_cave) diff --git a/worlds/aquaria/__init__.py b/worlds/aquaria/__init__.py index f620bf6d7306..1f7b956bb34b 100644 --- a/worlds/aquaria/__init__.py +++ b/worlds/aquaria/__init__.py @@ -7,9 +7,10 @@ from typing import List, Dict, ClassVar, Any from worlds.AutoWorld import World, WebWorld from BaseClasses import Tutorial, MultiWorld, ItemClassification -from .Items import item_table, AquariaItem, ItemType, ItemGroup -from .Locations import location_table -from .Options import AquariaOptions +from .Items import item_table, AquariaItem, ItemType, ItemGroup, ItemNames +from .Locations import location_table, AquariaLocationNames +from .Options import (AquariaOptions, IngredientRandomizer, TurtleRandomizer, EarlyBindSong, EarlyEnergyForm, + UnconfineHomeWater, Objective) from .Regions import AquariaRegions @@ -65,15 +66,15 @@ class AquariaWorld(World): web: WebWorld = AquariaWeb() "The web page generation informations" - item_name_to_id: ClassVar[Dict[str, int]] =\ + item_name_to_id: ClassVar[Dict[str, int]] = \ {name: data.id for name, data in item_table.items()} "The name and associated ID of each item of the world" item_name_groups = { - "Damage": {"Energy form", "Nature form", "Beast form", - "Li and Li song", "Baby Nautilus", "Baby Piranha", - "Baby Blaster"}, - "Light": {"Sun form", "Baby Dumbo"} + "Damage": {ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, + ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, + ItemNames.BABY_BLASTER}, + "Light": {ItemNames.SUN_FORM, ItemNames.BABY_DUMBO} } """Grouping item make it easier to find them""" @@ -148,23 +149,32 @@ def get_filler_item_name(self): def create_items(self) -> None: """Create every item in the world""" precollected = [item.name for item in self.multiworld.precollected_items[self.player]] - if self.options.turtle_randomizer.value > 0: - if self.options.turtle_randomizer.value == 2: - self.__pre_fill_item("Transturtle Final Boss", "Final Boss area, Transturtle", precollected) + if self.options.turtle_randomizer.value != TurtleRandomizer.option_none: + if self.options.turtle_randomizer.value == TurtleRandomizer.option_all_except_final: + self.__pre_fill_item(ItemNames.TRANSTURTLE_BODY, AquariaLocationNames.FINAL_BOSS_AREA_TRANSTURTLE, + precollected) else: - self.__pre_fill_item("Transturtle Veil top left", "The Veil top left area, Transturtle", precollected) - self.__pre_fill_item("Transturtle Veil top right", "The Veil top right area, Transturtle", precollected) - self.__pre_fill_item("Transturtle Open Water top right", "Open Water top right area, Transturtle", + self.__pre_fill_item(ItemNames.TRANSTURTLE_VEIL_TOP_LEFT, + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_TRANSTURTLE, precollected) + self.__pre_fill_item(ItemNames.TRANSTURTLE_VEIL_TOP_RIGHT, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_TRANSTURTLE, precollected) + self.__pre_fill_item(ItemNames.TRANSTURTLE_OPEN_WATERS, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_TRANSTURTLE, precollected) - self.__pre_fill_item("Transturtle Forest bottom left", "Kelp Forest bottom left area, Transturtle", + self.__pre_fill_item(ItemNames.TRANSTURTLE_KELP_FOREST, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_TRANSTURTLE, + precollected) + self.__pre_fill_item(ItemNames.TRANSTURTLE_HOME_WATERS, AquariaLocationNames.HOME_WATERS_TRANSTURTLE, + precollected) + self.__pre_fill_item(ItemNames.TRANSTURTLE_ABYSS, AquariaLocationNames.ABYSS_RIGHT_AREA_TRANSTURTLE, + precollected) + self.__pre_fill_item(ItemNames.TRANSTURTLE_BODY, AquariaLocationNames.FINAL_BOSS_AREA_TRANSTURTLE, precollected) - self.__pre_fill_item("Transturtle Home Water", "Home Water, Transturtle", precollected) - self.__pre_fill_item("Transturtle Abyss right", "Abyss right area, Transturtle", precollected) - self.__pre_fill_item("Transturtle Final Boss", "Final Boss area, Transturtle", precollected) # The last two are inverted because in the original game, they are special turtle that communicate directly - self.__pre_fill_item("Transturtle Simon Says", "Arnassi Ruins, Transturtle", precollected, - ItemClassification.progression) - self.__pre_fill_item("Transturtle Arnassi Ruins", "Simon Says area, Transturtle", precollected) + self.__pre_fill_item(ItemNames.TRANSTURTLE_SIMON_SAYS, AquariaLocationNames.ARNASSI_RUINS_TRANSTURTLE, + precollected, ItemClassification.progression) + self.__pre_fill_item(ItemNames.TRANSTURTLE_ARNASSI_RUINS, AquariaLocationNames.SIMON_SAYS_AREA_TRANSTURTLE, + precollected) for name, data in item_table.items(): if name not in self.exclude: for i in range(data.count): @@ -175,10 +185,17 @@ def set_rules(self) -> None: """ Launched when the Multiworld generator is ready to generate rules """ - + if self.options.early_energy_form == EarlyEnergyForm.option_early: + self.multiworld.early_items[self.player][ItemNames.ENERGY_FORM] = 1 + elif self.options.early_energy_form == EarlyEnergyForm.option_early_and_local: + self.multiworld.local_early_items[self.player][ItemNames.ENERGY_FORM] = 1 + if self.options.early_bind_song == EarlyBindSong.option_early: + self.multiworld.early_items[self.player][ItemNames.BIND_SONG] = 1 + elif self.options.early_bind_song == EarlyBindSong.option_early_and_local: + self.multiworld.local_early_items[self.player][ItemNames.BIND_SONG] = 1 self.regions.adjusting_rules(self.options) self.multiworld.completion_condition[self.player] = lambda \ - state: state.has("Victory", self.player) + state: state.has(ItemNames.VICTORY, self.player) def generate_basic(self) -> None: """ @@ -186,13 +203,13 @@ def generate_basic(self) -> None: Used to fill then `ingredients_substitution` list """ simple_ingredients_substitution = [i for i in range(27)] - if self.options.ingredient_randomizer.value > 0: - if self.options.ingredient_randomizer.value == 1: + if self.options.ingredient_randomizer.value > IngredientRandomizer.option_off: + if self.options.ingredient_randomizer.value == IngredientRandomizer.option_common_ingredients: simple_ingredients_substitution.pop(-1) simple_ingredients_substitution.pop(-1) simple_ingredients_substitution.pop(-1) self.random.shuffle(simple_ingredients_substitution) - if self.options.ingredient_randomizer.value == 1: + if self.options.ingredient_randomizer.value == IngredientRandomizer.option_common_ingredients: simple_ingredients_substitution.extend([24, 25, 26]) dishes_substitution = [i for i in range(27, 76)] if self.options.dish_randomizer: @@ -205,14 +222,19 @@ def fill_slot_data(self) -> Dict[str, Any]: return {"ingredientReplacement": self.ingredients_substitution, "aquarian_translate": bool(self.options.aquarian_translation.value), "blind_goal": bool(self.options.blind_goal.value), - "secret_needed": self.options.objective.value > 0, + "secret_needed": + self.options.objective.value == Objective.option_obtain_secrets_and_kill_the_creator, "minibosses_to_kill": self.options.mini_bosses_to_beat.value, "bigbosses_to_kill": self.options.big_bosses_to_beat.value, "skip_first_vision": bool(self.options.skip_first_vision.value), - "unconfine_home_water_energy_door": self.options.unconfine_home_water.value in [1, 3], - "unconfine_home_water_transturtle": self.options.unconfine_home_water.value in [2, 3], + "unconfine_home_water_energy_door": + self.options.unconfine_home_water.value == UnconfineHomeWater.option_via_energy_door + or self.options.unconfine_home_water.value == UnconfineHomeWater.option_via_both, + "unconfine_home_water_transturtle": + self.options.unconfine_home_water.value == UnconfineHomeWater.option_via_transturtle + or self.options.unconfine_home_water.value == UnconfineHomeWater.option_via_both, "bind_song_needed_to_get_under_rock_bulb": bool(self.options.bind_song_needed_to_get_under_rock_bulb), "no_progression_hard_or_hidden_locations": bool(self.options.no_progression_hard_or_hidden_locations), "light_needed_to_get_to_dark_places": bool(self.options.light_needed_to_get_to_dark_places), - "turtle_randomizer": self.options.turtle_randomizer.value, + "turtle_randomizer": self.options.turtle_randomizer.value } diff --git a/worlds/aquaria/docs/en_Aquaria.md b/worlds/aquaria/docs/en_Aquaria.md index c3e5f54dd66a..836a942be741 100644 --- a/worlds/aquaria/docs/en_Aquaria.md +++ b/worlds/aquaria/docs/en_Aquaria.md @@ -24,7 +24,7 @@ The locations in the randomizer are: * Beating Mithalan God boss * Fish Cave puzzle * Beating Drunian God boss - * Beating Sun God boss + * Beating Lumerean God boss * Breaking Li cage in the body Note that, unlike the vanilla game, when opening sing bulbs, Mithalas urns and Sunken City crates, diff --git a/worlds/aquaria/test/__init__.py b/worlds/aquaria/test/__init__.py index 8c4f64c3452c..05f46fc76259 100644 --- a/worlds/aquaria/test/__init__.py +++ b/worlds/aquaria/test/__init__.py @@ -6,211 +6,212 @@ from test.bases import WorldTestBase +from ..Locations import AquariaLocationNames # Every location accessible after the home water. after_home_water_locations = [ - "Sun Crystal", - "Home Water, Transturtle", - "Open Water top left area, bulb under the rock in the right path", - "Open Water top left area, bulb under the rock in the left path", - "Open Water top left area, bulb to the right of the save crystal", - "Open Water top right area, bulb in the small path before Mithalas", - "Open Water top right area, bulb in the path from the left entrance", - "Open Water top right area, bulb in the clearing close to the bottom exit", - "Open Water top right area, bulb in the big clearing close to the save crystal", - "Open Water top right area, bulb in the big clearing to the top exit", - "Open Water top right area, first urn in the Mithalas exit", - "Open Water top right area, second urn in the Mithalas exit", - "Open Water top right area, third urn in the Mithalas exit", - "Open Water top right area, bulb in the turtle room", - "Open Water top right area, Transturtle", - "Open Water bottom left area, bulb behind the chomper fish", - "Open Water bottom left area, bulb inside the lowest fish pass", - "Open Water skeleton path, bulb close to the right exit", - "Open Water skeleton path, bulb behind the chomper fish", - "Open Water skeleton path, King Skull", - "Arnassi Ruins, bulb in the right part", - "Arnassi Ruins, bulb in the left part", - "Arnassi Ruins, bulb in the center part", - "Arnassi Ruins, Song Plant Spore", - "Arnassi Ruins, Arnassi Armor", - "Arnassi Ruins, Arnassi Statue", - "Arnassi Ruins, Transturtle", - "Arnassi Ruins, Crab Armor", - "Simon Says area, Transturtle", - "Mithalas City, first bulb in the left city part", - "Mithalas City, second bulb in the left city part", - "Mithalas City, bulb in the right part", - "Mithalas City, bulb at the top of the city", - "Mithalas City, first bulb in a broken home", - "Mithalas City, second bulb in a broken home", - "Mithalas City, bulb in the bottom left part", - "Mithalas City, first bulb in one of the homes", - "Mithalas City, second bulb in one of the homes", - "Mithalas City, first urn in one of the homes", - "Mithalas City, second urn in one of the homes", - "Mithalas City, first urn in the city reserve", - "Mithalas City, second urn in the city reserve", - "Mithalas City, third urn in the city reserve", - "Mithalas City, first bulb at the end of the top path", - "Mithalas City, second bulb at the end of the top path", - "Mithalas City, bulb in the top path", - "Mithalas City, Mithalas Pot", - "Mithalas City, urn in the Castle flower tube entrance", - "Mithalas City, Doll", - "Mithalas City, urn inside a home fish pass", - "Mithalas City Castle, bulb in the flesh hole", - "Mithalas City Castle, Blue Banner", - "Mithalas City Castle, urn in the bedroom", - "Mithalas City Castle, first urn of the single lamp path", - "Mithalas City Castle, second urn of the single lamp path", - "Mithalas City Castle, urn in the bottom room", - "Mithalas City Castle, first urn on the entrance path", - "Mithalas City Castle, second urn on the entrance path", - "Mithalas City Castle, beating the Priests", - "Mithalas City Castle, Trident Head", - "Mithalas Cathedral, first urn in the top right room", - "Mithalas Cathedral, second urn in the top right room", - "Mithalas Cathedral, third urn in the top right room", - "Mithalas Cathedral, urn in the flesh room with fleas", - "Mithalas Cathedral, first urn in the bottom right path", - "Mithalas Cathedral, second urn in the bottom right path", - "Mithalas Cathedral, urn behind the flesh vein", - "Mithalas Cathedral, urn in the top left eyes boss room", - "Mithalas Cathedral, first urn in the path behind the flesh vein", - "Mithalas Cathedral, second urn in the path behind the flesh vein", - "Mithalas Cathedral, third urn in the path behind the flesh vein", - "Mithalas Cathedral, fourth urn in the top right room", - "Mithalas Cathedral, Mithalan Dress", - "Mithalas Cathedral, urn below the left entrance", - "Cathedral Underground, bulb in the center part", - "Cathedral Underground, first bulb in the top left part", - "Cathedral Underground, second bulb in the top left part", - "Cathedral Underground, third bulb in the top left part", - "Cathedral Underground, bulb close to the save crystal", - "Cathedral Underground, bulb in the bottom right path", - "Mithalas boss area, beating Mithalan God", - "Kelp Forest top left area, bulb in the bottom left clearing", - "Kelp Forest top left area, bulb in the path down from the top left clearing", - "Kelp Forest top left area, bulb in the top left clearing", - "Kelp Forest top left area, Jelly Egg", - "Kelp Forest top left area, bulb close to the Verse Egg", - "Kelp Forest top left area, Verse Egg", - "Kelp Forest top right area, bulb under the rock in the right path", - "Kelp Forest top right area, bulb at the left of the center clearing", - "Kelp Forest top right area, bulb in the left path's big room", - "Kelp Forest top right area, bulb in the left path's small room", - "Kelp Forest top right area, bulb at the top of the center clearing", - "Kelp Forest top right area, Black Pearl", - "Kelp Forest top right area, bulb in the top fish pass", - "Kelp Forest bottom left area, bulb close to the spirit crystals", - "Kelp Forest bottom left area, Walker Baby", - "Kelp Forest bottom left area, Transturtle", - "Kelp Forest bottom right area, Odd Container", - "Kelp Forest boss area, beating Drunian God", - "Kelp Forest boss room, bulb at the bottom of the area", - "Kelp Forest bottom left area, Fish Cave puzzle", - "Kelp Forest sprite cave, bulb inside the fish pass", - "Kelp Forest sprite cave, bulb in the second room", - "Kelp Forest sprite cave, Seed Bag", - "Mermog cave, bulb in the left part of the cave", - "Mermog cave, Piranha Egg", - "The Veil top left area, In Li's cave", - "The Veil top left area, bulb under the rock in the top right path", - "The Veil top left area, bulb hidden behind the blocking rock", - "The Veil top left area, Transturtle", - "The Veil top left area, bulb inside the fish pass", - "Turtle cave, Turtle Egg", - "Turtle cave, bulb in Bubble Cliff", - "Turtle cave, Urchin Costume", - "The Veil top right area, bulb in the middle of the wall jump cliff", - "The Veil top right area, Golden Starfish", - "The Veil top right area, bulb at the top of the waterfall", - "The Veil top right area, Transturtle", - "The Veil bottom area, bulb in the left path", - "The Veil bottom area, bulb in the spirit path", - "The Veil bottom area, Verse Egg", - "The Veil bottom area, Stone Head", - "Octopus Cave, Dumbo Egg", - "Octopus Cave, bulb in the path below the Octopus Cave path", - "Bubble Cave, bulb in the left cave wall", - "Bubble Cave, bulb in the right cave wall (behind the ice crystal)", - "Bubble Cave, Verse Egg", - "Sun Temple, bulb in the top left part", - "Sun Temple, bulb in the top right part", - "Sun Temple, bulb at the top of the high dark room", - "Sun Temple, Golden Gear", - "Sun Temple, first bulb of the temple", - "Sun Temple, bulb on the right part", - "Sun Temple, bulb in the hidden room of the right part", - "Sun Temple, Sun Key", - "Sun Worm path, first path bulb", - "Sun Worm path, second path bulb", - "Sun Worm path, first cliff bulb", - "Sun Worm path, second cliff bulb", - "Sun Temple boss area, beating Sun God", - "Abyss left area, bulb in hidden path room", - "Abyss left area, bulb in the right part", - "Abyss left area, Glowing Seed", - "Abyss left area, Glowing Plant", - "Abyss left area, bulb in the bottom fish pass", - "Abyss right area, bulb behind the rock in the whale room", - "Abyss right area, bulb in the middle path", - "Abyss right area, bulb behind the rock in the middle path", - "Abyss right area, bulb in the left green room", - "Abyss right area, Transturtle", - "Ice Cave, bulb in the room to the right", - "Ice Cave, first bulb in the top exit room", - "Ice Cave, second bulb in the top exit room", - "Ice Cave, third bulb in the top exit room", - "Ice Cave, bulb in the left room", - "King Jellyfish Cave, bulb in the right path from King Jelly", - "King Jellyfish Cave, Jellyfish Costume", - "The Whale, Verse Egg", - "Sunken City right area, crate close to the save crystal", - "Sunken City right area, crate in the left bottom room", - "Sunken City left area, crate in the little pipe room", - "Sunken City left area, crate close to the save crystal", - "Sunken City left area, crate before the bedroom", - "Sunken City left area, Girl Costume", - "Sunken City, bulb on top of the boss area", - "The Body center area, breaking Li's cage", - "The Body center area, bulb on the main path blocking tube", - "The Body left area, first bulb in the top face room", - "The Body left area, second bulb in the top face room", - "The Body left area, bulb below the water stream", - "The Body left area, bulb in the top path to the top face room", - "The Body left area, bulb in the bottom face room", - "The Body right area, bulb in the top face room", - "The Body right area, bulb in the top path to the bottom face room", - "The Body right area, bulb in the bottom face room", - "The Body bottom area, bulb in the Jelly Zap room", - "The Body bottom area, bulb in the nautilus room", - "The Body bottom area, Mutant Costume", - "Final Boss area, first bulb in the turtle room", - "Final Boss area, second bulb in the turtle room", - "Final Boss area, third bulb in the turtle room", - "Final Boss area, Transturtle", - "Final Boss area, bulb in the boss third form room", - "Simon Says area, beating Simon Says", - "Beating Fallen God", - "Beating Mithalan God", - "Beating Drunian God", - "Beating Sun God", - "Beating the Golem", - "Beating Nautilus Prime", - "Beating Blaster Peg Prime", - "Beating Mergog", - "Beating Mithalan priests", - "Beating Octopus Prime", - "Beating Crabbius Maximus", - "Beating Mantis Shrimp Prime", - "Beating King Jellyfish God Prime", - "First secret", - "Second secret", - "Third secret", - "Sunken City cleared", - "Objective complete", + AquariaLocationNames.SUN_CRYSTAL, + AquariaLocationNames.HOME_WATERS_TRANSTURTLE, + AquariaLocationNames.OPEN_WATERS_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH, + AquariaLocationNames.OPEN_WATERS_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_LEFT_PATH, + AquariaLocationNames.OPEN_WATERS_TOP_LEFT_AREA_BULB_TO_THE_RIGHT_OF_THE_SAVE_CRYSTAL, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_SMALL_PATH_BEFORE_MITHALAS, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_PATH_FROM_THE_LEFT_ENTRANCE, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_CLEARING_CLOSE_TO_THE_BOTTOM_EXIT, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_BIG_CLEARING_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_BIG_CLEARING_TO_THE_TOP_EXIT, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_FIRST_URN_IN_THE_MITHALAS_EXIT, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_SECOND_URN_IN_THE_MITHALAS_EXIT, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_THIRD_URN_IN_THE_MITHALAS_EXIT, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_TURTLE_ROOM, + AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_TRANSTURTLE, + AquariaLocationNames.OPEN_WATERS_BOTTOM_LEFT_AREA_BULB_BEHIND_THE_CHOMPER_FISH, + AquariaLocationNames.OPEN_WATERS_BOTTOM_LEFT_AREA_BULB_INSIDE_THE_LOWEST_FISH_PASS, + AquariaLocationNames.OPEN_WATERS_SKELETON_PATH_BULB_CLOSE_TO_THE_RIGHT_EXIT, + AquariaLocationNames.OPEN_WATERS_SKELETON_PATH_BULB_BEHIND_THE_CHOMPER_FISH, + AquariaLocationNames.OPEN_WATERS_SKELETON_PATH_KING_SKULL, + AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_RIGHT_PART, + AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_LEFT_PART, + AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_CENTER_PART, + AquariaLocationNames.ARNASSI_RUINS_SONG_PLANT_SPORE, + AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR, + AquariaLocationNames.ARNASSI_RUINS_ARNASSI_STATUE, + AquariaLocationNames.ARNASSI_RUINS_TRANSTURTLE, + AquariaLocationNames.ARNASSI_RUINS_CRAB_ARMOR, + AquariaLocationNames.SIMON_SAYS_AREA_TRANSTURTLE, + AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_THE_LEFT_CITY_PART, + AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_THE_LEFT_CITY_PART, + AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_RIGHT_PART, + AquariaLocationNames.MITHALAS_CITY_BULB_AT_THE_TOP_OF_THE_CITY, + AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_A_BROKEN_HOME, + AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_A_BROKEN_HOME, + AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_BOTTOM_LEFT_PART, + AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_ONE_OF_THE_HOMES, + AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_ONE_OF_THE_HOMES, + AquariaLocationNames.MITHALAS_CITY_FIRST_URN_IN_ONE_OF_THE_HOMES, + AquariaLocationNames.MITHALAS_CITY_SECOND_URN_IN_ONE_OF_THE_HOMES, + AquariaLocationNames.MITHALAS_CITY_FIRST_URN_IN_THE_CITY_RESERVE, + AquariaLocationNames.MITHALAS_CITY_SECOND_URN_IN_THE_CITY_RESERVE, + AquariaLocationNames.MITHALAS_CITY_THIRD_URN_IN_THE_CITY_RESERVE, + AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_AT_THE_END_OF_THE_TOP_PATH, + AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_AT_THE_END_OF_THE_TOP_PATH, + AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_TOP_PATH, + AquariaLocationNames.MITHALAS_CITY_MITHALAS_POT, + AquariaLocationNames.MITHALAS_CITY_URN_IN_THE_CASTLE_FLOWER_TUBE_ENTRANCE, + AquariaLocationNames.MITHALAS_CITY_DOLL, + AquariaLocationNames.MITHALAS_CITY_URN_INSIDE_A_HOME_FISH_PASS, + AquariaLocationNames.MITHALAS_CITY_CASTLE_BULB_IN_THE_FLESH_HOLE, + AquariaLocationNames.MITHALAS_CITY_CASTLE_BLUE_BANNER, + AquariaLocationNames.MITHALAS_CITY_CASTLE_URN_IN_THE_BEDROOM, + AquariaLocationNames.MITHALAS_CITY_CASTLE_FIRST_URN_OF_THE_SINGLE_LAMP_PATH, + AquariaLocationNames.MITHALAS_CITY_CASTLE_SECOND_URN_OF_THE_SINGLE_LAMP_PATH, + AquariaLocationNames.MITHALAS_CITY_CASTLE_URN_IN_THE_BOTTOM_ROOM, + AquariaLocationNames.MITHALAS_CITY_CASTLE_FIRST_URN_ON_THE_ENTRANCE_PATH, + AquariaLocationNames.MITHALAS_CITY_CASTLE_SECOND_URN_ON_THE_ENTRANCE_PATH, + AquariaLocationNames.MITHALAS_CITY_CASTLE_BEATING_THE_PRIESTS, + AquariaLocationNames.MITHALAS_CITY_CASTLE_TRIDENT_HEAD, + AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_TOP_RIGHT_ROOM, + AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_TOP_RIGHT_ROOM, + AquariaLocationNames.MITHALAS_CATHEDRAL_THIRD_URN_IN_THE_TOP_RIGHT_ROOM, + AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_BOTTOM_RIGHT_PATH, + AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_BOTTOM_RIGHT_PATH, + AquariaLocationNames.MITHALAS_CATHEDRAL_URN_BEHIND_THE_FLESH_VEIN, + AquariaLocationNames.MITHALAS_CATHEDRAL_URN_IN_THE_TOP_LEFT_EYES_BOSS_ROOM, + AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN, + AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN, + AquariaLocationNames.MITHALAS_CATHEDRAL_THIRD_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN, + AquariaLocationNames.MITHALAS_CATHEDRAL_FOURTH_URN_IN_THE_TOP_RIGHT_ROOM, + AquariaLocationNames.MITHALAS_CATHEDRAL_MITHALAN_DRESS, + AquariaLocationNames.MITHALAS_CATHEDRAL_URN_BELOW_THE_LEFT_ENTRANCE, + AquariaLocationNames.MITHALAS_CATHEDRAL_BULB_IN_THE_FLESH_ROOM_WITH_FLEAS, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_IN_THE_CENTER_PART, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_FIRST_BULB_IN_THE_TOP_LEFT_PART, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_SECOND_BULB_IN_THE_TOP_LEFT_PART, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_THIRD_BULB_IN_THE_TOP_LEFT_PART, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_IN_THE_BOTTOM_RIGHT_PATH, + AquariaLocationNames.MITHALAS_BOSS_AREA_BEATING_MITHALAN_GOD, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_BOTTOM_LEFT_CLEARING, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_PATH_DOWN_FROM_THE_TOP_LEFT_CLEARING, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_TOP_LEFT_CLEARING, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_JELLY_EGG, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_CLOSE_TO_THE_VERSE_EGG, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_VERSE_EGG, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_LEFT_OF_THE_CENTER_CLEARING, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_BIG_ROOM, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_SMALL_ROOM, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_CENTER_CLEARING, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BLACK_PEARL, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_TOP_FISH_PASS, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_BULB_CLOSE_TO_THE_SPIRIT_CRYSTALS, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_WALKER_BABY, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_TRANSTURTLE, + AquariaLocationNames.KELP_FOREST_BOTTOM_RIGHT_AREA_ODD_CONTAINER, + AquariaLocationNames.KELP_FOREST_BOSS_AREA_BEATING_DRUNIAN_GOD, + AquariaLocationNames.KELP_FOREST_BOSS_ROOM_BULB_AT_THE_BOTTOM_OF_THE_AREA, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_FISH_CAVE_PUZZLE, + AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_BULB_INSIDE_THE_FISH_PASS, + AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_BULB_IN_THE_SECOND_ROOM, + AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_SEED_BAG, + AquariaLocationNames.MERMOG_CAVE_BULB_IN_THE_LEFT_PART_OF_THE_CAVE, + AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG, + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_IN_LI_S_CAVE, + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_TOP_RIGHT_PATH, + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_HIDDEN_BEHIND_THE_BLOCKING_ROCK, + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_TRANSTURTLE, + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_INSIDE_THE_FISH_PASS, + AquariaLocationNames.TURTLE_CAVE_TURTLE_EGG, + AquariaLocationNames.TURTLE_CAVE_BULB_IN_BUBBLE_CLIFF, + AquariaLocationNames.TURTLE_CAVE_URCHIN_COSTUME, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_IN_THE_MIDDLE_OF_THE_WALL_JUMP_CLIFF, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_GOLDEN_STARFISH, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_TRANSTURTLE, + AquariaLocationNames.THE_VEIL_BOTTOM_AREA_BULB_IN_THE_LEFT_PATH, + AquariaLocationNames.THE_VEIL_BOTTOM_AREA_BULB_IN_THE_SPIRIT_PATH, + AquariaLocationNames.THE_VEIL_BOTTOM_AREA_VERSE_EGG, + AquariaLocationNames.THE_VEIL_BOTTOM_AREA_STONE_HEAD, + AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG, + AquariaLocationNames.OCTOPUS_CAVE_BULB_IN_THE_PATH_BELOW_THE_OCTOPUS_CAVE_PATH, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL, + AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG, + AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_LEFT_PART, + AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_RIGHT_PART, + AquariaLocationNames.SUN_TEMPLE_BULB_AT_THE_TOP_OF_THE_HIGH_DARK_ROOM, + AquariaLocationNames.SUN_TEMPLE_GOLDEN_GEAR, + AquariaLocationNames.SUN_TEMPLE_FIRST_BULB_OF_THE_TEMPLE, + AquariaLocationNames.SUN_TEMPLE_BULB_ON_THE_RIGHT_PART, + AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_HIDDEN_ROOM_OF_THE_RIGHT_PART, + AquariaLocationNames.SUN_TEMPLE_SUN_KEY, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_PATH_BULB, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_PATH_BULB, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB, + AquariaLocationNames.SUN_TEMPLE_BOSS_AREA_BEATING_LUMEREAN_GOD, + AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_HIDDEN_PATH_ROOM, + AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_THE_RIGHT_PART, + AquariaLocationNames.ABYSS_LEFT_AREA_GLOWING_SEED, + AquariaLocationNames.ABYSS_LEFT_AREA_GLOWING_PLANT, + AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_THE_BOTTOM_FISH_PASS, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_WHALE_ROOM, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_IN_THE_MIDDLE_PATH, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_MIDDLE_PATH, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_IN_THE_LEFT_GREEN_ROOM, + AquariaLocationNames.ABYSS_RIGHT_AREA_TRANSTURTLE, + AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_ROOM_TO_THE_RIGHT, + AquariaLocationNames.ICE_CAVERN_FIRST_BULB_IN_THE_TOP_EXIT_ROOM, + AquariaLocationNames.ICE_CAVERN_SECOND_BULB_IN_THE_TOP_EXIT_ROOM, + AquariaLocationNames.ICE_CAVERN_THIRD_BULB_IN_THE_TOP_EXIT_ROOM, + AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_LEFT_ROOM, + AquariaLocationNames.KING_JELLYFISH_CAVE_BULB_IN_THE_RIGHT_PATH_FROM_KING_JELLY, + AquariaLocationNames.KING_JELLYFISH_CAVE_JELLYFISH_COSTUME, + AquariaLocationNames.THE_WHALE_VERSE_EGG, + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_IN_THE_LEFT_BOTTOM_ROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_IN_THE_LITTLE_PIPE_ROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_BEFORE_THE_BEDROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_GIRL_COSTUME, + AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA, + AquariaLocationNames.THE_BODY_CENTER_AREA_BREAKING_LI_S_CAGE, + AquariaLocationNames.THE_BODY_CENTER_AREA_BULB_ON_THE_MAIN_PATH_BLOCKING_TUBE, + AquariaLocationNames.THE_BODY_LEFT_AREA_FIRST_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_SECOND_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_BELOW_THE_WATER_STREAM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_JELLY_ZAP_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_NAUTILUS_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_MUTANT_COSTUME, + AquariaLocationNames.FINAL_BOSS_AREA_FIRST_BULB_IN_THE_TURTLE_ROOM, + AquariaLocationNames.FINAL_BOSS_AREA_SECOND_BULB_IN_THE_TURTLE_ROOM, + AquariaLocationNames.FINAL_BOSS_AREA_THIRD_BULB_IN_THE_TURTLE_ROOM, + AquariaLocationNames.FINAL_BOSS_AREA_TRANSTURTLE, + AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM, + AquariaLocationNames.SIMON_SAYS_AREA_BEATING_SIMON_SAYS, + AquariaLocationNames.BEATING_FALLEN_GOD, + AquariaLocationNames.BEATING_MITHALAN_GOD, + AquariaLocationNames.BEATING_DRUNIAN_GOD, + AquariaLocationNames.BEATING_LUMEREAN_GOD, + AquariaLocationNames.BEATING_THE_GOLEM, + AquariaLocationNames.BEATING_NAUTILUS_PRIME, + AquariaLocationNames.BEATING_BLASTER_PEG_PRIME, + AquariaLocationNames.BEATING_MERGOG, + AquariaLocationNames.BEATING_MITHALAN_PRIESTS, + AquariaLocationNames.BEATING_OCTOPUS_PRIME, + AquariaLocationNames.BEATING_CRABBIUS_MAXIMUS, + AquariaLocationNames.BEATING_MANTIS_SHRIMP_PRIME, + AquariaLocationNames.BEATING_KING_JELLYFISH_GOD_PRIME, + AquariaLocationNames.FIRST_SECRET, + AquariaLocationNames.SECOND_SECRET, + AquariaLocationNames.THIRD_SECRET, + AquariaLocationNames.SUNKEN_CITY_CLEARED, + AquariaLocationNames.OBJECTIVE_COMPLETE, ] class AquariaTestBase(WorldTestBase): diff --git a/worlds/aquaria/test/test_beast_form_access.py b/worlds/aquaria/test/test_beast_form_access.py index c09586269d38..684c33115ffc 100644 --- a/worlds/aquaria/test/test_beast_form_access.py +++ b/worlds/aquaria/test/test_beast_form_access.py @@ -5,6 +5,8 @@ """ from . import AquariaTestBase +from ..Items import ItemNames +from ..Locations import AquariaLocationNames class BeastFormAccessTest(AquariaTestBase): @@ -13,16 +15,16 @@ class BeastFormAccessTest(AquariaTestBase): def test_beast_form_location(self) -> None: """Test locations that require beast form""" locations = [ - "Mermog cave, Piranha Egg", - "Kelp Forest top left area, Jelly Egg", - "Mithalas Cathedral, Mithalan Dress", - "The Veil top right area, bulb at the top of the waterfall", - "Sunken City, bulb on top of the boss area", - "Octopus Cave, Dumbo Egg", - "Beating the Golem", - "Beating Mergog", - "Beating Octopus Prime", - "Sunken City cleared", + AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_JELLY_EGG, + AquariaLocationNames.MITHALAS_CATHEDRAL_MITHALAN_DRESS, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL, + AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA, + AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG, + AquariaLocationNames.BEATING_THE_GOLEM, + AquariaLocationNames.BEATING_MERGOG, + AquariaLocationNames.BEATING_OCTOPUS_PRIME, + AquariaLocationNames.SUNKEN_CITY_CLEARED, ] - items = [["Beast form"]] + items = [[ItemNames.BEAST_FORM]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_beast_form_or_arnassi_armor_access.py b/worlds/aquaria/test/test_beast_form_or_arnassi_armor_access.py index fa4c6923400a..4c93c309a119 100644 --- a/worlds/aquaria/test/test_beast_form_or_arnassi_armor_access.py +++ b/worlds/aquaria/test/test_beast_form_or_arnassi_armor_access.py @@ -5,6 +5,8 @@ """ from . import AquariaTestBase +from ..Items import ItemNames +from ..Locations import AquariaLocationNames class BeastForArnassiArmormAccessTest(AquariaTestBase): @@ -13,27 +15,27 @@ class BeastForArnassiArmormAccessTest(AquariaTestBase): def test_beast_form_arnassi_armor_location(self) -> None: """Test locations that require beast form or arnassi armor""" locations = [ - "Mithalas City Castle, beating the Priests", - "Arnassi Ruins, Crab Armor", - "Arnassi Ruins, Song Plant Spore", - "Mithalas City, first bulb at the end of the top path", - "Mithalas City, second bulb at the end of the top path", - "Mithalas City, bulb in the top path", - "Mithalas City, Mithalas Pot", - "Mithalas City, urn in the Castle flower tube entrance", - "Mermog cave, Piranha Egg", - "Mithalas Cathedral, Mithalan Dress", - "Kelp Forest top left area, Jelly Egg", - "The Veil top right area, bulb in the middle of the wall jump cliff", - "The Veil top right area, bulb at the top of the waterfall", - "Sunken City, bulb on top of the boss area", - "Octopus Cave, Dumbo Egg", - "Beating the Golem", - "Beating Mergog", - "Beating Crabbius Maximus", - "Beating Octopus Prime", - "Beating Mithalan priests", - "Sunken City cleared" + AquariaLocationNames.MITHALAS_CITY_CASTLE_BEATING_THE_PRIESTS, + AquariaLocationNames.ARNASSI_RUINS_CRAB_ARMOR, + AquariaLocationNames.ARNASSI_RUINS_SONG_PLANT_SPORE, + AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_AT_THE_END_OF_THE_TOP_PATH, + AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_AT_THE_END_OF_THE_TOP_PATH, + AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_TOP_PATH, + AquariaLocationNames.MITHALAS_CITY_MITHALAS_POT, + AquariaLocationNames.MITHALAS_CITY_URN_IN_THE_CASTLE_FLOWER_TUBE_ENTRANCE, + AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG, + AquariaLocationNames.MITHALAS_CATHEDRAL_MITHALAN_DRESS, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_JELLY_EGG, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_IN_THE_MIDDLE_OF_THE_WALL_JUMP_CLIFF, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL, + AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA, + AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG, + AquariaLocationNames.BEATING_THE_GOLEM, + AquariaLocationNames.BEATING_MERGOG, + AquariaLocationNames.BEATING_CRABBIUS_MAXIMUS, + AquariaLocationNames.BEATING_OCTOPUS_PRIME, + AquariaLocationNames.BEATING_MITHALAN_PRIESTS, + AquariaLocationNames.SUNKEN_CITY_CLEARED ] - items = [["Beast form", "Arnassi Armor"]] + items = [[ItemNames.BEAST_FORM, ItemNames.ARNASSI_ARMOR]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_bind_song_access.py b/worlds/aquaria/test/test_bind_song_access.py index 05f96edb9192..689f487c644e 100644 --- a/worlds/aquaria/test/test_bind_song_access.py +++ b/worlds/aquaria/test/test_bind_song_access.py @@ -6,31 +6,36 @@ """ from . import AquariaTestBase, after_home_water_locations +from ..Items import ItemNames +from ..Locations import AquariaLocationNames +from ..Options import UnconfineHomeWater, EarlyBindSong class BindSongAccessTest(AquariaTestBase): """Unit test used to test accessibility of locations with and without the bind song""" options = { "bind_song_needed_to_get_under_rock_bulb": False, + "unconfine_home_water": UnconfineHomeWater.option_off, + "early_bind_song": EarlyBindSong.option_off } def test_bind_song_location(self) -> None: """Test locations that require Bind song""" locations = [ - "Verse Cave right area, Big Seed", - "Home Water, bulb in the path below Nautilus Prime", - "Home Water, bulb in the bottom left room", - "Home Water, Nautilus Egg", - "Song Cave, Verse Egg", - "Energy Temple first area, beating the Energy Statue", - "Energy Temple first area, bulb in the bottom room blocked by a rock", - "Energy Temple first area, Energy Idol", - "Energy Temple second area, bulb under the rock", - "Energy Temple bottom entrance, Krotite Armor", - "Energy Temple third area, bulb in the bottom path", - "Energy Temple boss area, Fallen God Tooth", - "Energy Temple blaster room, Blaster Egg", + AquariaLocationNames.VERSE_CAVE_RIGHT_AREA_BIG_SEED, + AquariaLocationNames.HOME_WATERS_BULB_IN_THE_PATH_BELOW_NAUTILUS_PRIME, + AquariaLocationNames.HOME_WATERS_BULB_IN_THE_BOTTOM_LEFT_ROOM, + AquariaLocationNames.HOME_WATERS_NAUTILUS_EGG, + AquariaLocationNames.SONG_CAVE_VERSE_EGG, + AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BEATING_THE_ENERGY_STATUE, + AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BULB_IN_THE_BOTTOM_ROOM_BLOCKED_BY_A_ROCK, + AquariaLocationNames.ENERGY_TEMPLE_ENERGY_IDOL, + AquariaLocationNames.ENERGY_TEMPLE_SECOND_AREA_BULB_UNDER_THE_ROCK, + AquariaLocationNames.ENERGY_TEMPLE_BOTTOM_ENTRANCE_KROTITE_ARMOR, + AquariaLocationNames.ENERGY_TEMPLE_THIRD_AREA_BULB_IN_THE_BOTTOM_PATH, + AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH, + AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG, *after_home_water_locations ] - items = [["Bind song"]] + items = [[ItemNames.BIND_SONG]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_bind_song_option_access.py b/worlds/aquaria/test/test_bind_song_option_access.py index e391eef101bf..74dfa2ed7094 100644 --- a/worlds/aquaria/test/test_bind_song_option_access.py +++ b/worlds/aquaria/test/test_bind_song_option_access.py @@ -7,6 +7,8 @@ from . import AquariaTestBase from .test_bind_song_access import after_home_water_locations +from ..Items import ItemNames +from ..Locations import AquariaLocationNames class BindSongOptionAccessTest(AquariaTestBase): @@ -18,25 +20,25 @@ class BindSongOptionAccessTest(AquariaTestBase): def test_bind_song_location(self) -> None: """Test locations that require Bind song with the bind song needed option activated""" locations = [ - "Verse Cave right area, Big Seed", - "Verse Cave left area, bulb under the rock at the end of the path", - "Home Water, bulb under the rock in the left path from the Verse Cave", - "Song Cave, bulb under the rock close to the song door", - "Song Cave, bulb under the rock in the path to the singing statues", - "Naija's Home, bulb under the rock at the right of the main path", - "Home Water, bulb in the path below Nautilus Prime", - "Home Water, bulb in the bottom left room", - "Home Water, Nautilus Egg", - "Song Cave, Verse Egg", - "Energy Temple first area, beating the Energy Statue", - "Energy Temple first area, bulb in the bottom room blocked by a rock", - "Energy Temple first area, Energy Idol", - "Energy Temple second area, bulb under the rock", - "Energy Temple bottom entrance, Krotite Armor", - "Energy Temple third area, bulb in the bottom path", - "Energy Temple boss area, Fallen God Tooth", - "Energy Temple blaster room, Blaster Egg", + AquariaLocationNames.VERSE_CAVE_RIGHT_AREA_BIG_SEED, + AquariaLocationNames.VERSE_CAVE_LEFT_AREA_BULB_UNDER_THE_ROCK_AT_THE_END_OF_THE_PATH, + AquariaLocationNames.HOME_WATERS_BULB_UNDER_THE_ROCK_IN_THE_LEFT_PATH_FROM_THE_VERSE_CAVE, + AquariaLocationNames.SONG_CAVE_BULB_UNDER_THE_ROCK_CLOSE_TO_THE_SONG_DOOR, + AquariaLocationNames.SONG_CAVE_BULB_UNDER_THE_ROCK_IN_THE_PATH_TO_THE_SINGING_STATUES, + AquariaLocationNames.NAIJA_S_HOME_BULB_UNDER_THE_ROCK_AT_THE_RIGHT_OF_THE_MAIN_PATH, + AquariaLocationNames.HOME_WATERS_BULB_IN_THE_PATH_BELOW_NAUTILUS_PRIME, + AquariaLocationNames.HOME_WATERS_BULB_IN_THE_BOTTOM_LEFT_ROOM, + AquariaLocationNames.HOME_WATERS_NAUTILUS_EGG, + AquariaLocationNames.SONG_CAVE_VERSE_EGG, + AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BEATING_THE_ENERGY_STATUE, + AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BULB_IN_THE_BOTTOM_ROOM_BLOCKED_BY_A_ROCK, + AquariaLocationNames.ENERGY_TEMPLE_ENERGY_IDOL, + AquariaLocationNames.ENERGY_TEMPLE_SECOND_AREA_BULB_UNDER_THE_ROCK, + AquariaLocationNames.ENERGY_TEMPLE_BOTTOM_ENTRANCE_KROTITE_ARMOR, + AquariaLocationNames.ENERGY_TEMPLE_THIRD_AREA_BULB_IN_THE_BOTTOM_PATH, + AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH, + AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG, *after_home_water_locations ] - items = [["Bind song"]] + items = [[ItemNames.BIND_SONG]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_confined_home_water.py b/worlds/aquaria/test/test_confined_home_water.py index 89c51ac5c775..d809a3d5cb68 100644 --- a/worlds/aquaria/test/test_confined_home_water.py +++ b/worlds/aquaria/test/test_confined_home_water.py @@ -5,16 +5,17 @@ """ from . import AquariaTestBase +from ..Options import UnconfineHomeWater, EarlyEnergyForm class ConfinedHomeWaterAccessTest(AquariaTestBase): """Unit test used to test accessibility of region with the unconfine home water option disabled""" options = { - "unconfine_home_water": 0, - "early_energy_form": False + "unconfine_home_water": UnconfineHomeWater.option_off, + "early_energy_form": EarlyEnergyForm.option_off } def test_confine_home_water_location(self) -> None: """Test region accessible with confined home water""" - self.assertFalse(self.can_reach_region("Open Water top left area"), "Can reach Open Water top left area") - self.assertFalse(self.can_reach_region("Home Water, turtle room"), "Can reach Home Water, turtle room") + self.assertFalse(self.can_reach_region("Open Waters top left area"), "Can reach Open Waters top left area") + self.assertFalse(self.can_reach_region("Home Waters, turtle room"), "Can reach Home Waters, turtle room") diff --git a/worlds/aquaria/test/test_dual_song_access.py b/worlds/aquaria/test/test_dual_song_access.py index bb9b2e739604..448d9df0ef3e 100644 --- a/worlds/aquaria/test/test_dual_song_access.py +++ b/worlds/aquaria/test/test_dual_song_access.py @@ -5,22 +5,25 @@ """ from . import AquariaTestBase +from ..Items import ItemNames +from ..Locations import AquariaLocationNames +from ..Options import TurtleRandomizer class LiAccessTest(AquariaTestBase): """Unit test used to test accessibility of locations with and without the dual song""" options = { - "turtle_randomizer": 1, + "turtle_randomizer": TurtleRandomizer.option_all, } def test_li_song_location(self) -> None: """Test locations that require the dual song""" locations = [ - "The Body bottom area, bulb in the Jelly Zap room", - "The Body bottom area, bulb in the nautilus room", - "The Body bottom area, Mutant Costume", - "Final Boss area, bulb in the boss third form room", - "Objective complete" + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_JELLY_ZAP_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_NAUTILUS_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_MUTANT_COSTUME, + AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM, + AquariaLocationNames.OBJECTIVE_COMPLETE ] - items = [["Dual form"]] + items = [[ItemNames.DUAL_FORM]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_energy_form_access.py b/worlds/aquaria/test/test_energy_form_access.py index b443166823bc..7eeb7c2e73c4 100644 --- a/worlds/aquaria/test/test_energy_form_access.py +++ b/worlds/aquaria/test/test_energy_form_access.py @@ -6,28 +6,31 @@ """ from . import AquariaTestBase +from ..Items import ItemNames +from ..Locations import AquariaLocationNames +from ..Options import EarlyEnergyForm class EnergyFormAccessTest(AquariaTestBase): """Unit test used to test accessibility of locations with and without the energy form""" options = { - "early_energy_form": False, + "early_energy_form": EarlyEnergyForm.option_off } def test_energy_form_location(self) -> None: """Test locations that require Energy form""" locations = [ - "Energy Temple second area, bulb under the rock", - "Energy Temple third area, bulb in the bottom path", - "The Body left area, first bulb in the top face room", - "The Body left area, second bulb in the top face room", - "The Body left area, bulb below the water stream", - "The Body left area, bulb in the top path to the top face room", - "The Body left area, bulb in the bottom face room", - "The Body right area, bulb in the top path to the bottom face room", - "The Body right area, bulb in the bottom face room", - "Final Boss area, bulb in the boss third form room", - "Objective complete", + AquariaLocationNames.ENERGY_TEMPLE_SECOND_AREA_BULB_UNDER_THE_ROCK, + AquariaLocationNames.ENERGY_TEMPLE_THIRD_AREA_BULB_IN_THE_BOTTOM_PATH, + AquariaLocationNames.THE_BODY_LEFT_AREA_FIRST_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_SECOND_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_BELOW_THE_WATER_STREAM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM, + AquariaLocationNames.OBJECTIVE_COMPLETE, ] - items = [["Energy form"]] + items = [[ItemNames.ENERGY_FORM]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_energy_form_or_dual_form_access.py b/worlds/aquaria/test/test_energy_form_or_dual_form_access.py index 8a765bc4e4e2..ba04405eea59 100644 --- a/worlds/aquaria/test/test_energy_form_or_dual_form_access.py +++ b/worlds/aquaria/test/test_energy_form_or_dual_form_access.py @@ -5,88 +5,74 @@ """ from . import AquariaTestBase +from ..Items import ItemNames +from ..Locations import AquariaLocationNames +from ..Options import EarlyEnergyForm, TurtleRandomizer class EnergyFormDualFormAccessTest(AquariaTestBase): """Unit test used to test accessibility of locations with and without the energy form and dual form (and Li)""" options = { - "early_energy_form": False, + "early_energy_form": EarlyEnergyForm.option_off, + "turtle_randomizer": TurtleRandomizer.option_all } def test_energy_form_or_dual_form_location(self) -> None: """Test locations that require Energy form or dual form""" locations = [ - "Naija's Home, bulb after the energy door", - "Home Water, Nautilus Egg", - "Energy Temple second area, bulb under the rock", - "Energy Temple bottom entrance, Krotite Armor", - "Energy Temple third area, bulb in the bottom path", - "Energy Temple blaster room, Blaster Egg", - "Energy Temple boss area, Fallen God Tooth", - "Mithalas City Castle, beating the Priests", - "Mithalas boss area, beating Mithalan God", - "Mithalas Cathedral, first urn in the top right room", - "Mithalas Cathedral, second urn in the top right room", - "Mithalas Cathedral, third urn in the top right room", - "Mithalas Cathedral, urn in the flesh room with fleas", - "Mithalas Cathedral, first urn in the bottom right path", - "Mithalas Cathedral, second urn in the bottom right path", - "Mithalas Cathedral, urn behind the flesh vein", - "Mithalas Cathedral, urn in the top left eyes boss room", - "Mithalas Cathedral, first urn in the path behind the flesh vein", - "Mithalas Cathedral, second urn in the path behind the flesh vein", - "Mithalas Cathedral, third urn in the path behind the flesh vein", - "Mithalas Cathedral, fourth urn in the top right room", - "Mithalas Cathedral, Mithalan Dress", - "Mithalas Cathedral, urn below the left entrance", - "Kelp Forest top left area, bulb close to the Verse Egg", - "Kelp Forest top left area, Verse Egg", - "Kelp Forest boss area, beating Drunian God", - "Mermog cave, Piranha Egg", - "Octopus Cave, Dumbo Egg", - "Sun Temple boss area, beating Sun God", - "King Jellyfish Cave, bulb in the right path from King Jelly", - "King Jellyfish Cave, Jellyfish Costume", - "Sunken City right area, crate close to the save crystal", - "Sunken City right area, crate in the left bottom room", - "Sunken City left area, crate in the little pipe room", - "Sunken City left area, crate close to the save crystal", - "Sunken City left area, crate before the bedroom", - "Sunken City left area, Girl Costume", - "Sunken City, bulb on top of the boss area", - "The Body center area, breaking Li's cage", - "The Body center area, bulb on the main path blocking tube", - "The Body left area, first bulb in the top face room", - "The Body left area, second bulb in the top face room", - "The Body left area, bulb below the water stream", - "The Body left area, bulb in the top path to the top face room", - "The Body left area, bulb in the bottom face room", - "The Body right area, bulb in the top face room", - "The Body right area, bulb in the top path to the bottom face room", - "The Body right area, bulb in the bottom face room", - "The Body bottom area, bulb in the Jelly Zap room", - "The Body bottom area, bulb in the nautilus room", - "The Body bottom area, Mutant Costume", - "Final Boss area, bulb in the boss third form room", - "Final Boss area, first bulb in the turtle room", - "Final Boss area, second bulb in the turtle room", - "Final Boss area, third bulb in the turtle room", - "Final Boss area, Transturtle", - "Beating Fallen God", - "Beating Blaster Peg Prime", - "Beating Mithalan God", - "Beating Drunian God", - "Beating Sun God", - "Beating the Golem", - "Beating Nautilus Prime", - "Beating Mergog", - "Beating Mithalan priests", - "Beating Octopus Prime", - "Beating King Jellyfish God Prime", - "Beating the Golem", - "Sunken City cleared", - "First secret", - "Objective complete" + AquariaLocationNames.NAIJA_S_HOME_BULB_AFTER_THE_ENERGY_DOOR, + AquariaLocationNames.HOME_WATERS_NAUTILUS_EGG, + AquariaLocationNames.ENERGY_TEMPLE_SECOND_AREA_BULB_UNDER_THE_ROCK, + AquariaLocationNames.ENERGY_TEMPLE_BOTTOM_ENTRANCE_KROTITE_ARMOR, + AquariaLocationNames.ENERGY_TEMPLE_THIRD_AREA_BULB_IN_THE_BOTTOM_PATH, + AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG, + AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH, + AquariaLocationNames.MITHALAS_CITY_CASTLE_BEATING_THE_PRIESTS, + AquariaLocationNames.MITHALAS_BOSS_AREA_BEATING_MITHALAN_GOD, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_CLOSE_TO_THE_VERSE_EGG, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_VERSE_EGG, + AquariaLocationNames.KELP_FOREST_BOSS_AREA_BEATING_DRUNIAN_GOD, + AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG, + AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG, + AquariaLocationNames.SUN_TEMPLE_BOSS_AREA_BEATING_LUMEREAN_GOD, + AquariaLocationNames.KING_JELLYFISH_CAVE_BULB_IN_THE_RIGHT_PATH_FROM_KING_JELLY, + AquariaLocationNames.KING_JELLYFISH_CAVE_JELLYFISH_COSTUME, + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_IN_THE_LEFT_BOTTOM_ROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_IN_THE_LITTLE_PIPE_ROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_BEFORE_THE_BEDROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_GIRL_COSTUME, + AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA, + AquariaLocationNames.THE_BODY_CENTER_AREA_BREAKING_LI_S_CAGE, + AquariaLocationNames.THE_BODY_CENTER_AREA_BULB_ON_THE_MAIN_PATH_BLOCKING_TUBE, + AquariaLocationNames.THE_BODY_LEFT_AREA_FIRST_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_SECOND_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_BELOW_THE_WATER_STREAM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_JELLY_ZAP_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_NAUTILUS_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_MUTANT_COSTUME, + AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM, + AquariaLocationNames.BEATING_FALLEN_GOD, + AquariaLocationNames.BEATING_BLASTER_PEG_PRIME, + AquariaLocationNames.BEATING_MITHALAN_GOD, + AquariaLocationNames.BEATING_DRUNIAN_GOD, + AquariaLocationNames.BEATING_LUMEREAN_GOD, + AquariaLocationNames.BEATING_THE_GOLEM, + AquariaLocationNames.BEATING_NAUTILUS_PRIME, + AquariaLocationNames.BEATING_MERGOG, + AquariaLocationNames.BEATING_MITHALAN_PRIESTS, + AquariaLocationNames.BEATING_OCTOPUS_PRIME, + AquariaLocationNames.BEATING_KING_JELLYFISH_GOD_PRIME, + AquariaLocationNames.BEATING_THE_GOLEM, + AquariaLocationNames.SUNKEN_CITY_CLEARED, + AquariaLocationNames.FIRST_SECRET, + AquariaLocationNames.OBJECTIVE_COMPLETE ] - items = [["Energy form", "Dual form", "Li and Li song", "Body tongue cleared"]] + items = [[ItemNames.ENERGY_FORM, ItemNames.DUAL_FORM, ItemNames.LI_AND_LI_SONG, ItemNames.BODY_TONGUE_CLEARED]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_fish_form_access.py b/worlds/aquaria/test/test_fish_form_access.py index 40b15a87cd35..3cbc750c7039 100644 --- a/worlds/aquaria/test/test_fish_form_access.py +++ b/worlds/aquaria/test/test_fish_form_access.py @@ -5,33 +5,36 @@ """ from . import AquariaTestBase +from ..Items import ItemNames +from ..Locations import AquariaLocationNames +from ..Options import TurtleRandomizer class FishFormAccessTest(AquariaTestBase): """Unit test used to test accessibility of locations with and without the fish form""" options = { - "turtle_randomizer": 1, + "turtle_randomizer": TurtleRandomizer.option_all, } def test_fish_form_location(self) -> None: """Test locations that require fish form""" locations = [ - "The Veil top left area, bulb inside the fish pass", - "Energy Temple first area, Energy Idol", - "Mithalas City, Doll", - "Mithalas City, urn inside a home fish pass", - "Kelp Forest top right area, bulb in the top fish pass", - "The Veil bottom area, Verse Egg", - "Open Water bottom left area, bulb inside the lowest fish pass", - "Kelp Forest top left area, bulb close to the Verse Egg", - "Kelp Forest top left area, Verse Egg", - "Mermog cave, bulb in the left part of the cave", - "Mermog cave, Piranha Egg", - "Beating Mergog", - "Octopus Cave, Dumbo Egg", - "Octopus Cave, bulb in the path below the Octopus Cave path", - "Beating Octopus Prime", - "Abyss left area, bulb in the bottom fish pass" + AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_INSIDE_THE_FISH_PASS, + AquariaLocationNames.ENERGY_TEMPLE_ENERGY_IDOL, + AquariaLocationNames.MITHALAS_CITY_DOLL, + AquariaLocationNames.MITHALAS_CITY_URN_INSIDE_A_HOME_FISH_PASS, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_TOP_FISH_PASS, + AquariaLocationNames.THE_VEIL_BOTTOM_AREA_VERSE_EGG, + AquariaLocationNames.OPEN_WATERS_BOTTOM_LEFT_AREA_BULB_INSIDE_THE_LOWEST_FISH_PASS, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_CLOSE_TO_THE_VERSE_EGG, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_VERSE_EGG, + AquariaLocationNames.MERMOG_CAVE_BULB_IN_THE_LEFT_PART_OF_THE_CAVE, + AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG, + AquariaLocationNames.BEATING_MERGOG, + AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG, + AquariaLocationNames.OCTOPUS_CAVE_BULB_IN_THE_PATH_BELOW_THE_OCTOPUS_CAVE_PATH, + AquariaLocationNames.BEATING_OCTOPUS_PRIME, + AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_THE_BOTTOM_FISH_PASS ] - items = [["Fish form"]] + items = [[ItemNames.FISH_FORM]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_li_song_access.py b/worlds/aquaria/test/test_li_song_access.py index f615fb10c640..6c8d6e5eebba 100644 --- a/worlds/aquaria/test/test_li_song_access.py +++ b/worlds/aquaria/test/test_li_song_access.py @@ -5,41 +5,44 @@ """ from . import AquariaTestBase +from ..Items import ItemNames +from ..Locations import AquariaLocationNames +from ..Options import TurtleRandomizer class LiAccessTest(AquariaTestBase): """Unit test used to test accessibility of locations with and without Li""" options = { - "turtle_randomizer": 1, + "turtle_randomizer": TurtleRandomizer.option_all, } def test_li_song_location(self) -> None: """Test locations that require Li""" locations = [ - "Sunken City right area, crate close to the save crystal", - "Sunken City right area, crate in the left bottom room", - "Sunken City left area, crate in the little pipe room", - "Sunken City left area, crate close to the save crystal", - "Sunken City left area, crate before the bedroom", - "Sunken City left area, Girl Costume", - "Sunken City, bulb on top of the boss area", - "The Body center area, breaking Li's cage", - "The Body center area, bulb on the main path blocking tube", - "The Body left area, first bulb in the top face room", - "The Body left area, second bulb in the top face room", - "The Body left area, bulb below the water stream", - "The Body left area, bulb in the top path to the top face room", - "The Body left area, bulb in the bottom face room", - "The Body right area, bulb in the top face room", - "The Body right area, bulb in the top path to the bottom face room", - "The Body right area, bulb in the bottom face room", - "The Body bottom area, bulb in the Jelly Zap room", - "The Body bottom area, bulb in the nautilus room", - "The Body bottom area, Mutant Costume", - "Final Boss area, bulb in the boss third form room", - "Beating the Golem", - "Sunken City cleared", - "Objective complete" + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_IN_THE_LEFT_BOTTOM_ROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_IN_THE_LITTLE_PIPE_ROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_BEFORE_THE_BEDROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_GIRL_COSTUME, + AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA, + AquariaLocationNames.THE_BODY_CENTER_AREA_BREAKING_LI_S_CAGE, + AquariaLocationNames.THE_BODY_CENTER_AREA_BULB_ON_THE_MAIN_PATH_BLOCKING_TUBE, + AquariaLocationNames.THE_BODY_LEFT_AREA_FIRST_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_SECOND_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_BELOW_THE_WATER_STREAM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_JELLY_ZAP_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_NAUTILUS_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_MUTANT_COSTUME, + AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM, + AquariaLocationNames.BEATING_THE_GOLEM, + AquariaLocationNames.SUNKEN_CITY_CLEARED, + AquariaLocationNames.OBJECTIVE_COMPLETE ] - items = [["Li and Li song", "Body tongue cleared"]] + items = [[ItemNames.LI_AND_LI_SONG, ItemNames.BODY_TONGUE_CLEARED]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_light_access.py b/worlds/aquaria/test/test_light_access.py index 29d37d790b20..ca668505f7d2 100644 --- a/worlds/aquaria/test/test_light_access.py +++ b/worlds/aquaria/test/test_light_access.py @@ -5,12 +5,15 @@ """ from . import AquariaTestBase +from ..Items import ItemNames +from ..Locations import AquariaLocationNames +from ..Options import TurtleRandomizer class LightAccessTest(AquariaTestBase): """Unit test used to test accessibility of locations with and without light""" options = { - "turtle_randomizer": 1, + "turtle_randomizer": TurtleRandomizer.option_all, "light_needed_to_get_to_dark_places": True, } @@ -19,52 +22,52 @@ def test_light_location(self) -> None: locations = [ # Since the `assertAccessDependency` sweep for events even if I tell it not to, those location cannot be # tested. - # "Third secret", - # "Sun Temple, bulb in the top left part", - # "Sun Temple, bulb in the top right part", - # "Sun Temple, bulb at the top of the high dark room", - # "Sun Temple, Golden Gear", - # "Sun Worm path, first path bulb", - # "Sun Worm path, second path bulb", - # "Sun Worm path, first cliff bulb", - "Octopus Cave, Dumbo Egg", - "Kelp Forest bottom right area, Odd Container", - "Kelp Forest top right area, Black Pearl", - "Abyss left area, bulb in hidden path room", - "Abyss left area, bulb in the right part", - "Abyss left area, Glowing Seed", - "Abyss left area, Glowing Plant", - "Abyss left area, bulb in the bottom fish pass", - "Abyss right area, bulb behind the rock in the whale room", - "Abyss right area, bulb in the middle path", - "Abyss right area, bulb behind the rock in the middle path", - "Abyss right area, bulb in the left green room", - "Ice Cave, bulb in the room to the right", - "Ice Cave, first bulb in the top exit room", - "Ice Cave, second bulb in the top exit room", - "Ice Cave, third bulb in the top exit room", - "Ice Cave, bulb in the left room", - "Bubble Cave, bulb in the left cave wall", - "Bubble Cave, bulb in the right cave wall (behind the ice crystal)", - "Bubble Cave, Verse Egg", - "Beating Mantis Shrimp Prime", - "King Jellyfish Cave, bulb in the right path from King Jelly", - "King Jellyfish Cave, Jellyfish Costume", - "Beating King Jellyfish God Prime", - "The Whale, Verse Egg", - "First secret", - "Sunken City right area, crate close to the save crystal", - "Sunken City right area, crate in the left bottom room", - "Sunken City left area, crate in the little pipe room", - "Sunken City left area, crate close to the save crystal", - "Sunken City left area, crate before the bedroom", - "Sunken City left area, Girl Costume", - "Sunken City, bulb on top of the boss area", - "Sunken City cleared", - "Beating the Golem", - "Beating Octopus Prime", - "Final Boss area, bulb in the boss third form room", - "Objective complete", + # AquariaLocationNames.THIRD_SECRET, + # AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_LEFT_PART, + # AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_RIGHT_PART, + # AquariaLocationNames.SUN_TEMPLE_BULB_AT_THE_TOP_OF_THE_HIGH_DARK_ROOM, + # AquariaLocationNames.SUN_TEMPLE_GOLDEN_GEAR, + # AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_PATH_BULB, + # AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_PATH_BULB, + # AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB, + AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG, + AquariaLocationNames.KELP_FOREST_BOTTOM_RIGHT_AREA_ODD_CONTAINER, + AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BLACK_PEARL, + AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_HIDDEN_PATH_ROOM, + AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_THE_RIGHT_PART, + AquariaLocationNames.ABYSS_LEFT_AREA_GLOWING_SEED, + AquariaLocationNames.ABYSS_LEFT_AREA_GLOWING_PLANT, + AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_THE_BOTTOM_FISH_PASS, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_WHALE_ROOM, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_IN_THE_MIDDLE_PATH, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_MIDDLE_PATH, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_IN_THE_LEFT_GREEN_ROOM, + AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_ROOM_TO_THE_RIGHT, + AquariaLocationNames.ICE_CAVERN_FIRST_BULB_IN_THE_TOP_EXIT_ROOM, + AquariaLocationNames.ICE_CAVERN_SECOND_BULB_IN_THE_TOP_EXIT_ROOM, + AquariaLocationNames.ICE_CAVERN_THIRD_BULB_IN_THE_TOP_EXIT_ROOM, + AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_LEFT_ROOM, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL, + AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG, + AquariaLocationNames.BEATING_MANTIS_SHRIMP_PRIME, + AquariaLocationNames.KING_JELLYFISH_CAVE_BULB_IN_THE_RIGHT_PATH_FROM_KING_JELLY, + AquariaLocationNames.KING_JELLYFISH_CAVE_JELLYFISH_COSTUME, + AquariaLocationNames.BEATING_KING_JELLYFISH_GOD_PRIME, + AquariaLocationNames.THE_WHALE_VERSE_EGG, + AquariaLocationNames.FIRST_SECRET, + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_IN_THE_LEFT_BOTTOM_ROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_IN_THE_LITTLE_PIPE_ROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_BEFORE_THE_BEDROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_GIRL_COSTUME, + AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA, + AquariaLocationNames.SUNKEN_CITY_CLEARED, + AquariaLocationNames.BEATING_THE_GOLEM, + AquariaLocationNames.BEATING_OCTOPUS_PRIME, + AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM, + AquariaLocationNames.OBJECTIVE_COMPLETE, ] - items = [["Sun form", "Baby Dumbo", "Has sun crystal"]] + items = [[ItemNames.SUN_FORM, ItemNames.BABY_DUMBO, ItemNames.HAS_SUN_CRYSTAL]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_nature_form_access.py b/worlds/aquaria/test/test_nature_form_access.py index 1d3b8f4150eb..61aebaef4816 100644 --- a/worlds/aquaria/test/test_nature_form_access.py +++ b/worlds/aquaria/test/test_nature_form_access.py @@ -5,53 +5,56 @@ """ from . import AquariaTestBase +from ..Items import ItemNames +from ..Locations import AquariaLocationNames +from ..Options import TurtleRandomizer class NatureFormAccessTest(AquariaTestBase): """Unit test used to test accessibility of locations with and without the nature form""" options = { - "turtle_randomizer": 1, + "turtle_randomizer": TurtleRandomizer.option_all, } def test_nature_form_location(self) -> None: """Test locations that require nature form""" locations = [ - "Song Cave, Anemone Seed", - "Energy Temple blaster room, Blaster Egg", - "Beating Blaster Peg Prime", - "Kelp Forest top left area, Verse Egg", - "Kelp Forest top left area, bulb close to the Verse Egg", - "Mithalas City Castle, beating the Priests", - "Kelp Forest sprite cave, bulb in the second room", - "Kelp Forest sprite cave, Seed Bag", - "Beating Mithalan priests", - "Abyss left area, bulb in the bottom fish pass", - "Bubble Cave, Verse Egg", - "Beating Mantis Shrimp Prime", - "Sunken City right area, crate close to the save crystal", - "Sunken City right area, crate in the left bottom room", - "Sunken City left area, crate in the little pipe room", - "Sunken City left area, crate close to the save crystal", - "Sunken City left area, crate before the bedroom", - "Sunken City left area, Girl Costume", - "Sunken City, bulb on top of the boss area", - "Beating the Golem", - "Sunken City cleared", - "The Body center area, breaking Li's cage", - "The Body center area, bulb on the main path blocking tube", - "The Body left area, first bulb in the top face room", - "The Body left area, second bulb in the top face room", - "The Body left area, bulb below the water stream", - "The Body left area, bulb in the top path to the top face room", - "The Body left area, bulb in the bottom face room", - "The Body right area, bulb in the top face room", - "The Body right area, bulb in the top path to the bottom face room", - "The Body right area, bulb in the bottom face room", - "The Body bottom area, bulb in the Jelly Zap room", - "The Body bottom area, bulb in the nautilus room", - "The Body bottom area, Mutant Costume", - "Final Boss area, bulb in the boss third form room", - "Objective complete" + AquariaLocationNames.SONG_CAVE_ANEMONE_SEED, + AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG, + AquariaLocationNames.BEATING_BLASTER_PEG_PRIME, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_VERSE_EGG, + AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_CLOSE_TO_THE_VERSE_EGG, + AquariaLocationNames.MITHALAS_CITY_CASTLE_BEATING_THE_PRIESTS, + AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_BULB_IN_THE_SECOND_ROOM, + AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_SEED_BAG, + AquariaLocationNames.BEATING_MITHALAN_PRIESTS, + AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_THE_BOTTOM_FISH_PASS, + AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG, + AquariaLocationNames.BEATING_MANTIS_SHRIMP_PRIME, + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.SUNKEN_CITY_RIGHT_AREA_CRATE_IN_THE_LEFT_BOTTOM_ROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_IN_THE_LITTLE_PIPE_ROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_CLOSE_TO_THE_SAVE_CRYSTAL, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_CRATE_BEFORE_THE_BEDROOM, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_GIRL_COSTUME, + AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA, + AquariaLocationNames.BEATING_THE_GOLEM, + AquariaLocationNames.SUNKEN_CITY_CLEARED, + AquariaLocationNames.THE_BODY_CENTER_AREA_BREAKING_LI_S_CAGE, + AquariaLocationNames.THE_BODY_CENTER_AREA_BULB_ON_THE_MAIN_PATH_BLOCKING_TUBE, + AquariaLocationNames.THE_BODY_LEFT_AREA_FIRST_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_SECOND_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_BELOW_THE_WATER_STREAM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_LEFT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_TOP_PATH_TO_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_RIGHT_AREA_BULB_IN_THE_BOTTOM_FACE_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_JELLY_ZAP_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_BULB_IN_THE_NAUTILUS_ROOM, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_MUTANT_COSTUME, + AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM, + AquariaLocationNames.OBJECTIVE_COMPLETE ] - items = [["Nature form"]] + items = [[ItemNames.NATURE_FORM]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_no_progression_hard_hidden_locations.py b/worlds/aquaria/test/test_no_progression_hard_hidden_locations.py index 517af3028dd2..65139088f27c 100644 --- a/worlds/aquaria/test/test_no_progression_hard_hidden_locations.py +++ b/worlds/aquaria/test/test_no_progression_hard_hidden_locations.py @@ -6,6 +6,7 @@ from . import AquariaTestBase from BaseClasses import ItemClassification +from ..Locations import AquariaLocationNames class UNoProgressionHardHiddenTest(AquariaTestBase): @@ -15,31 +16,31 @@ class UNoProgressionHardHiddenTest(AquariaTestBase): } unfillable_locations = [ - "Energy Temple boss area, Fallen God Tooth", - "Mithalas boss area, beating Mithalan God", - "Kelp Forest boss area, beating Drunian God", - "Sun Temple boss area, beating Sun God", - "Sunken City, bulb on top of the boss area", - "Home Water, Nautilus Egg", - "Energy Temple blaster room, Blaster Egg", - "Mithalas City Castle, beating the Priests", - "Mermog cave, Piranha Egg", - "Octopus Cave, Dumbo Egg", - "King Jellyfish Cave, bulb in the right path from King Jelly", - "King Jellyfish Cave, Jellyfish Costume", - "Final Boss area, bulb in the boss third form room", - "Sun Worm path, first cliff bulb", - "Sun Worm path, second cliff bulb", - "The Veil top right area, bulb at the top of the waterfall", - "Bubble Cave, bulb in the left cave wall", - "Bubble Cave, bulb in the right cave wall (behind the ice crystal)", - "Bubble Cave, Verse Egg", - "Kelp Forest bottom left area, bulb close to the spirit crystals", - "Kelp Forest bottom left area, Walker Baby", - "Sun Temple, Sun Key", - "The Body bottom area, Mutant Costume", - "Sun Temple, bulb in the hidden room of the right part", - "Arnassi Ruins, Arnassi Armor", + AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH, + AquariaLocationNames.MITHALAS_BOSS_AREA_BEATING_MITHALAN_GOD, + AquariaLocationNames.KELP_FOREST_BOSS_AREA_BEATING_DRUNIAN_GOD, + AquariaLocationNames.SUN_TEMPLE_BOSS_AREA_BEATING_LUMEREAN_GOD, + AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA, + AquariaLocationNames.HOME_WATERS_NAUTILUS_EGG, + AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG, + AquariaLocationNames.MITHALAS_CITY_CASTLE_BEATING_THE_PRIESTS, + AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG, + AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG, + AquariaLocationNames.KING_JELLYFISH_CAVE_BULB_IN_THE_RIGHT_PATH_FROM_KING_JELLY, + AquariaLocationNames.KING_JELLYFISH_CAVE_JELLYFISH_COSTUME, + AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL, + AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_BULB_CLOSE_TO_THE_SPIRIT_CRYSTALS, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_WALKER_BABY, + AquariaLocationNames.SUN_TEMPLE_SUN_KEY, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_MUTANT_COSTUME, + AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_HIDDEN_ROOM_OF_THE_RIGHT_PART, + AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR, ] def test_unconfine_home_water_both_location_fillable(self) -> None: diff --git a/worlds/aquaria/test/test_progression_hard_hidden_locations.py b/worlds/aquaria/test/test_progression_hard_hidden_locations.py index a1493c5d0f39..f6ac8e0e17e2 100644 --- a/worlds/aquaria/test/test_progression_hard_hidden_locations.py +++ b/worlds/aquaria/test/test_progression_hard_hidden_locations.py @@ -5,6 +5,7 @@ """ from . import AquariaTestBase +from ..Locations import AquariaLocationNames class UNoProgressionHardHiddenTest(AquariaTestBase): @@ -14,31 +15,31 @@ class UNoProgressionHardHiddenTest(AquariaTestBase): } unfillable_locations = [ - "Energy Temple boss area, Fallen God Tooth", - "Mithalas boss area, beating Mithalan God", - "Kelp Forest boss area, beating Drunian God", - "Sun Temple boss area, beating Sun God", - "Sunken City, bulb on top of the boss area", - "Home Water, Nautilus Egg", - "Energy Temple blaster room, Blaster Egg", - "Mithalas City Castle, beating the Priests", - "Mermog cave, Piranha Egg", - "Octopus Cave, Dumbo Egg", - "King Jellyfish Cave, bulb in the right path from King Jelly", - "King Jellyfish Cave, Jellyfish Costume", - "Final Boss area, bulb in the boss third form room", - "Sun Worm path, first cliff bulb", - "Sun Worm path, second cliff bulb", - "The Veil top right area, bulb at the top of the waterfall", - "Bubble Cave, bulb in the left cave wall", - "Bubble Cave, bulb in the right cave wall (behind the ice crystal)", - "Bubble Cave, Verse Egg", - "Kelp Forest bottom left area, bulb close to the spirit crystals", - "Kelp Forest bottom left area, Walker Baby", - "Sun Temple, Sun Key", - "The Body bottom area, Mutant Costume", - "Sun Temple, bulb in the hidden room of the right part", - "Arnassi Ruins, Arnassi Armor", + AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH, + AquariaLocationNames.MITHALAS_BOSS_AREA_BEATING_MITHALAN_GOD, + AquariaLocationNames.KELP_FOREST_BOSS_AREA_BEATING_DRUNIAN_GOD, + AquariaLocationNames.SUN_TEMPLE_BOSS_AREA_BEATING_LUMEREAN_GOD, + AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA, + AquariaLocationNames.HOME_WATERS_NAUTILUS_EGG, + AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG, + AquariaLocationNames.MITHALAS_CITY_CASTLE_BEATING_THE_PRIESTS, + AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG, + AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG, + AquariaLocationNames.KING_JELLYFISH_CAVE_BULB_IN_THE_RIGHT_PATH_FROM_KING_JELLY, + AquariaLocationNames.KING_JELLYFISH_CAVE_JELLYFISH_COSTUME, + AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB, + AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB, + AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL, + AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_BULB_CLOSE_TO_THE_SPIRIT_CRYSTALS, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_WALKER_BABY, + AquariaLocationNames.SUN_TEMPLE_SUN_KEY, + AquariaLocationNames.THE_BODY_BOTTOM_AREA_MUTANT_COSTUME, + AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_HIDDEN_ROOM_OF_THE_RIGHT_PART, + AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR, ] def test_unconfine_home_water_both_location_fillable(self) -> None: diff --git a/worlds/aquaria/test/test_spirit_form_access.py b/worlds/aquaria/test/test_spirit_form_access.py index 7e31de9905e9..834661e0bd4d 100644 --- a/worlds/aquaria/test/test_spirit_form_access.py +++ b/worlds/aquaria/test/test_spirit_form_access.py @@ -5,6 +5,8 @@ """ from . import AquariaTestBase +from ..Items import ItemNames +from ..Locations import AquariaLocationNames class SpiritFormAccessTest(AquariaTestBase): @@ -13,23 +15,23 @@ class SpiritFormAccessTest(AquariaTestBase): def test_spirit_form_location(self) -> None: """Test locations that require spirit form""" locations = [ - "The Veil bottom area, bulb in the spirit path", - "Mithalas City Castle, Trident Head", - "Open Water skeleton path, King Skull", - "Kelp Forest bottom left area, Walker Baby", - "Abyss right area, bulb behind the rock in the whale room", - "The Whale, Verse Egg", - "Ice Cave, bulb in the room to the right", - "Ice Cave, first bulb in the top exit room", - "Ice Cave, second bulb in the top exit room", - "Ice Cave, third bulb in the top exit room", - "Ice Cave, bulb in the left room", - "Bubble Cave, bulb in the left cave wall", - "Bubble Cave, bulb in the right cave wall (behind the ice crystal)", - "Bubble Cave, Verse Egg", - "Sunken City left area, Girl Costume", - "Beating Mantis Shrimp Prime", - "First secret", + AquariaLocationNames.THE_VEIL_BOTTOM_AREA_BULB_IN_THE_SPIRIT_PATH, + AquariaLocationNames.MITHALAS_CITY_CASTLE_TRIDENT_HEAD, + AquariaLocationNames.OPEN_WATERS_SKELETON_PATH_KING_SKULL, + AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_WALKER_BABY, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_WHALE_ROOM, + AquariaLocationNames.THE_WHALE_VERSE_EGG, + AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_ROOM_TO_THE_RIGHT, + AquariaLocationNames.ICE_CAVERN_FIRST_BULB_IN_THE_TOP_EXIT_ROOM, + AquariaLocationNames.ICE_CAVERN_SECOND_BULB_IN_THE_TOP_EXIT_ROOM, + AquariaLocationNames.ICE_CAVERN_THIRD_BULB_IN_THE_TOP_EXIT_ROOM, + AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_LEFT_ROOM, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL, + AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL, + AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG, + AquariaLocationNames.SUNKEN_CITY_LEFT_AREA_GIRL_COSTUME, + AquariaLocationNames.BEATING_MANTIS_SHRIMP_PRIME, + AquariaLocationNames.FIRST_SECRET, ] - items = [["Spirit form"]] + items = [[ItemNames.SPIRIT_FORM]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_sun_form_access.py b/worlds/aquaria/test/test_sun_form_access.py index 394d5e4b27ae..b37cceeed9c3 100644 --- a/worlds/aquaria/test/test_sun_form_access.py +++ b/worlds/aquaria/test/test_sun_form_access.py @@ -5,6 +5,8 @@ """ from . import AquariaTestBase +from ..Items import ItemNames +from ..Locations import AquariaLocationNames class SunFormAccessTest(AquariaTestBase): @@ -13,16 +15,16 @@ class SunFormAccessTest(AquariaTestBase): def test_sun_form_location(self) -> None: """Test locations that require sun form""" locations = [ - "First secret", - "The Whale, Verse Egg", - "Abyss right area, bulb behind the rock in the whale room", - "Octopus Cave, Dumbo Egg", - "Beating Octopus Prime", - "Sunken City, bulb on top of the boss area", - "Beating the Golem", - "Sunken City cleared", - "Final Boss area, bulb in the boss third form room", - "Objective complete" + AquariaLocationNames.FIRST_SECRET, + AquariaLocationNames.THE_WHALE_VERSE_EGG, + AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_WHALE_ROOM, + AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG, + AquariaLocationNames.BEATING_OCTOPUS_PRIME, + AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA, + AquariaLocationNames.BEATING_THE_GOLEM, + AquariaLocationNames.SUNKEN_CITY_CLEARED, + AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM, + AquariaLocationNames.OBJECTIVE_COMPLETE ] - items = [["Sun form"]] + items = [[ItemNames.SUN_FORM]] self.assertAccessDependency(locations, items) diff --git a/worlds/aquaria/test/test_unconfine_home_water_via_both.py b/worlds/aquaria/test/test_unconfine_home_water_via_both.py index 5b8689bc53a2..038e27782a16 100644 --- a/worlds/aquaria/test/test_unconfine_home_water_via_both.py +++ b/worlds/aquaria/test/test_unconfine_home_water_via_both.py @@ -6,16 +6,17 @@ """ from . import AquariaTestBase +from ..Options import UnconfineHomeWater, EarlyEnergyForm class UnconfineHomeWaterBothAccessTest(AquariaTestBase): """Unit test used to test accessibility of region with the unconfine home water option enabled""" options = { - "unconfine_home_water": 3, - "early_energy_form": False + "unconfine_home_water": UnconfineHomeWater.option_via_both, + "early_energy_form": EarlyEnergyForm.option_off } def test_unconfine_home_water_both_location(self) -> None: """Test locations accessible with unconfined home water via energy door and transportation turtle""" - self.assertTrue(self.can_reach_region("Open Water top left area"), "Cannot reach Open Water top left area") - self.assertTrue(self.can_reach_region("Home Water, turtle room"), "Cannot reach Home Water, turtle room") + self.assertTrue(self.can_reach_region("Open Waters top left area"), "Cannot reach Open Waters top left area") + self.assertTrue(self.can_reach_region("Home Waters, turtle room"), "Cannot reach Home Waters, turtle room") diff --git a/worlds/aquaria/test/test_unconfine_home_water_via_energy_door.py b/worlds/aquaria/test/test_unconfine_home_water_via_energy_door.py index 37a5c98610b5..269a4b33837e 100644 --- a/worlds/aquaria/test/test_unconfine_home_water_via_energy_door.py +++ b/worlds/aquaria/test/test_unconfine_home_water_via_energy_door.py @@ -5,16 +5,17 @@ """ from . import AquariaTestBase +from ..Options import UnconfineHomeWater, EarlyEnergyForm class UnconfineHomeWaterEnergyDoorAccessTest(AquariaTestBase): """Unit test used to test accessibility of region with the unconfine home water option enabled""" options = { - "unconfine_home_water": 1, - "early_energy_form": False + "unconfine_home_water": UnconfineHomeWater.option_via_energy_door, + "early_energy_form": EarlyEnergyForm.option_off } def test_unconfine_home_water_energy_door_location(self) -> None: """Test locations accessible with unconfined home water via energy door""" - self.assertTrue(self.can_reach_region("Open Water top left area"), "Cannot reach Open Water top left area") - self.assertFalse(self.can_reach_region("Home Water, turtle room"), "Can reach Home Water, turtle room") + self.assertTrue(self.can_reach_region("Open Waters top left area"), "Cannot reach Open Waters top left area") + self.assertFalse(self.can_reach_region("Home Waters, turtle room"), "Can reach Home Waters, turtle room") diff --git a/worlds/aquaria/test/test_unconfine_home_water_via_transturtle.py b/worlds/aquaria/test/test_unconfine_home_water_via_transturtle.py index da4c83c2bc7f..b8efb82471c4 100644 --- a/worlds/aquaria/test/test_unconfine_home_water_via_transturtle.py +++ b/worlds/aquaria/test/test_unconfine_home_water_via_transturtle.py @@ -5,16 +5,17 @@ """ from . import AquariaTestBase +from ..Options import UnconfineHomeWater, EarlyEnergyForm class UnconfineHomeWaterTransturtleAccessTest(AquariaTestBase): """Unit test used to test accessibility of region with the unconfine home water option enabled""" options = { - "unconfine_home_water": 2, - "early_energy_form": False + "unconfine_home_water": UnconfineHomeWater.option_via_transturtle, + "early_energy_form": EarlyEnergyForm.option_off } def test_unconfine_home_water_transturtle_location(self) -> None: """Test locations accessible with unconfined home water via transportation turtle""" - self.assertTrue(self.can_reach_region("Home Water, turtle room"), "Cannot reach Home Water, turtle room") - self.assertFalse(self.can_reach_region("Open Water top left area"), "Can reach Open Water top left area") + self.assertTrue(self.can_reach_region("Home Waters, turtle room"), "Cannot reach Home Waters, turtle room") + self.assertFalse(self.can_reach_region("Open Waters top left area"), "Can reach Open Waters top left area") From 51c4fe8f67511850a7d26fe07a183242683034f1 Mon Sep 17 00:00:00 2001 From: Jouramie <16137441+Jouramie@users.noreply.github.com> Date: Sun, 8 Dec 2024 21:00:30 -0500 Subject: [PATCH 005/144] Stardew Valley: Fix a bug where walnutsanity would get deactivated even tho ginger island got forced activated (and move some files) (#4311) --- worlds/stardew_valley/__init__.py | 40 +- worlds/stardew_valley/items.py | 32 +- worlds/stardew_valley/logic/walnut_logic.py | 22 +- worlds/stardew_valley/option_groups.py | 76 ---- worlds/stardew_valley/options/__init__.py | 6 + .../stardew_valley/options/forced_options.py | 48 +++ .../stardew_valley/options/option_groups.py | 68 ++++ .../stardew_valley/{ => options}/options.py | 23 +- worlds/stardew_valley/options/presets.py | 371 +++++++++++++++++ worlds/stardew_valley/presets.py | 376 ------------------ worlds/stardew_valley/rules.py | 10 +- .../strings/ap_names/ap_option_names.py | 35 +- .../strings/ap_names/mods/__init__.py | 0 worlds/stardew_valley/test/TestBooksanity.py | 1 - worlds/stardew_valley/test/TestOptions.py | 34 +- .../stardew_valley/test/TestOptionsPairs.py | 19 +- worlds/stardew_valley/test/TestRegions.py | 9 +- .../stardew_valley/test/TestWalnutsanity.py | 12 +- worlds/stardew_valley/test/__init__.py | 59 +-- worlds/stardew_valley/test/mods/TestMods.py | 5 +- .../test/options/TestForcedOptions.py | 84 ++++ .../test/{ => options}/TestPresets.py | 10 +- .../stardew_valley/test/options/__init__.py | 0 worlds/stardew_valley/test/options/utils.py | 68 ++++ .../test/stability/TestUniversalTracker.py | 4 +- 25 files changed, 752 insertions(+), 660 deletions(-) delete mode 100644 worlds/stardew_valley/option_groups.py create mode 100644 worlds/stardew_valley/options/__init__.py create mode 100644 worlds/stardew_valley/options/forced_options.py create mode 100644 worlds/stardew_valley/options/option_groups.py rename worlds/stardew_valley/{ => options}/options.py (97%) create mode 100644 worlds/stardew_valley/options/presets.py delete mode 100644 worlds/stardew_valley/presets.py create mode 100644 worlds/stardew_valley/strings/ap_names/mods/__init__.py create mode 100644 worlds/stardew_valley/test/options/TestForcedOptions.py rename worlds/stardew_valley/test/{ => options}/TestPresets.py (86%) create mode 100644 worlds/stardew_valley/test/options/__init__.py create mode 100644 worlds/stardew_valley/test/options/utils.py diff --git a/worlds/stardew_valley/__init__.py b/worlds/stardew_valley/__init__.py index 34c617f5013a..6ba0e35e0a3a 100644 --- a/worlds/stardew_valley/__init__.py +++ b/worlds/stardew_valley/__init__.py @@ -3,7 +3,7 @@ from typing import Dict, Any, Iterable, Optional, Union, List, TextIO from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState -from Options import PerGameCommonOptions, Accessibility +from Options import PerGameCommonOptions from worlds.AutoWorld import World, WebWorld from . import rules from .bundles.bundle_room import BundleRoom @@ -15,10 +15,11 @@ from .logic.bundle_logic import BundleLogic from .logic.logic import StardewLogic from .logic.time_logic import MAX_MONTHS -from .option_groups import sv_option_groups -from .options import StardewValleyOptions, SeasonRandomization, Goal, BundleRandomization, BundlePrice, EnabledFillerBuffs, NumberOfMovementBuffs, \ - BackpackProgression, BuildingProgression, ExcludeGingerIsland, TrapItems, EntranceRandomization, FarmType, Walnutsanity -from .presets import sv_options_presets +from .options import StardewValleyOptions, SeasonRandomization, Goal, BundleRandomization, EnabledFillerBuffs, NumberOfMovementBuffs, \ + BuildingProgression, ExcludeGingerIsland, TrapItems, EntranceRandomization, FarmType, Walnutsanity +from .options.forced_options import force_change_options_if_incompatible +from .options.option_groups import sv_option_groups +from .options.presets import sv_options_presets from .regions import create_regions from .rules import set_rules from .stardew_rule import True_, StardewRule, HasProgressionPercent, true_ @@ -112,36 +113,9 @@ def interpret_slot_data(self, slot_data: Dict[str, Any]) -> Optional[int]: return seed def generate_early(self): - self.force_change_options_if_incompatible() + force_change_options_if_incompatible(self.options, self.player, self.player_name) self.content = create_content(self.options) - def force_change_options_if_incompatible(self): - goal_is_walnut_hunter = self.options.goal == Goal.option_greatest_walnut_hunter - goal_is_perfection = self.options.goal == Goal.option_perfection - goal_is_island_related = goal_is_walnut_hunter or goal_is_perfection - exclude_ginger_island = self.options.exclude_ginger_island == ExcludeGingerIsland.option_true - - if goal_is_island_related and exclude_ginger_island: - self.options.exclude_ginger_island.value = ExcludeGingerIsland.option_false - goal_name = self.options.goal.current_key - logger.warning( - f"Goal '{goal_name}' requires Ginger Island. Exclude Ginger Island setting forced to 'False' for player {self.player} ({self.player_name})") - - if exclude_ginger_island and self.options.walnutsanity != Walnutsanity.preset_none: - self.options.walnutsanity.value = Walnutsanity.preset_none - logger.warning( - f"Walnutsanity requires Ginger Island. Ginger Island was excluded from {self.player} ({self.player_name})'s world, so walnutsanity was force disabled") - - if goal_is_perfection and self.options.accessibility == Accessibility.option_minimal: - self.options.accessibility.value = Accessibility.option_full - logger.warning( - f"Goal 'Perfection' requires full accessibility. Accessibility setting forced to 'Full' for player {self.player} ({self.player_name})") - - elif self.options.goal == Goal.option_allsanity and self.options.accessibility == Accessibility.option_minimal: - self.options.accessibility.value = Accessibility.option_full - logger.warning( - f"Goal 'Allsanity' requires full accessibility. Accessibility setting forced to 'Full' for player {self.player} ({self.player_name})") - def create_regions(self): def create_region(name: str, exits: Iterable[str]) -> Region: region = Region(name, self.player, self.multiworld) diff --git a/worlds/stardew_valley/items.py b/worlds/stardew_valley/items.py index 3d852a37f402..6ac827f869cc 100644 --- a/worlds/stardew_valley/items.py +++ b/worlds/stardew_valley/items.py @@ -17,7 +17,7 @@ from .options import StardewValleyOptions, TrapItems, FestivalLocations, ExcludeGingerIsland, SpecialOrderLocations, SeasonRandomization, Museumsanity, \ BuildingProgression, ToolProgression, ElevatorProgression, BackpackProgression, ArcadeMachineLocations, Monstersanity, Goal, \ Chefsanity, Craftsanity, BundleRandomization, EntranceRandomization, Shipsanity, Walnutsanity, EnabledFillerBuffs -from .strings.ap_names.ap_option_names import OptionName +from .strings.ap_names.ap_option_names import BuffOptionName, WalnutsanityOptionName from .strings.ap_names.ap_weapon_names import APWeapon from .strings.ap_names.buff_names import Buff from .strings.ap_names.community_upgrade_names import CommunityUpgrade @@ -538,16 +538,16 @@ def create_walnuts(item_factory: StardewItemFactory, options: StardewValleyOptio num_penta_walnuts = 1 # https://stardewvalleywiki.com/Golden_Walnut # Totals should be accurate, but distribution is slightly offset to make room for baseline walnuts - if OptionName.walnutsanity_puzzles in walnutsanity: # 61 + if WalnutsanityOptionName.puzzles in walnutsanity: # 61 num_single_walnuts += 6 # 6 num_triple_walnuts += 5 # 15 num_penta_walnuts += 8 # 40 - if OptionName.walnutsanity_bushes in walnutsanity: # 25 + if WalnutsanityOptionName.bushes in walnutsanity: # 25 num_single_walnuts += 16 # 16 num_triple_walnuts += 3 # 9 - if OptionName.walnutsanity_dig_spots in walnutsanity: # 18 + if WalnutsanityOptionName.dig_spots in walnutsanity: # 18 num_single_walnuts += 18 # 18 - if OptionName.walnutsanity_repeatables in walnutsanity: # 33 + if WalnutsanityOptionName.repeatables in walnutsanity: # 33 num_single_walnuts += 30 # 30 num_triple_walnuts += 1 # 3 @@ -833,27 +833,27 @@ def get_all_filler_items(include_traps: bool, exclude_ginger_island: bool) -> Li def get_allowed_player_buffs(buff_option: EnabledFillerBuffs) -> List[ItemData]: allowed_buffs = [] - if OptionName.buff_luck in buff_option: + if BuffOptionName.luck in buff_option: allowed_buffs.append(item_table[Buff.luck]) - if OptionName.buff_damage in buff_option: + if BuffOptionName.damage in buff_option: allowed_buffs.append(item_table[Buff.damage]) - if OptionName.buff_defense in buff_option: + if BuffOptionName.defense in buff_option: allowed_buffs.append(item_table[Buff.defense]) - if OptionName.buff_immunity in buff_option: + if BuffOptionName.immunity in buff_option: allowed_buffs.append(item_table[Buff.immunity]) - if OptionName.buff_health in buff_option: + if BuffOptionName.health in buff_option: allowed_buffs.append(item_table[Buff.health]) - if OptionName.buff_energy in buff_option: + if BuffOptionName.energy in buff_option: allowed_buffs.append(item_table[Buff.energy]) - if OptionName.buff_bite in buff_option: + if BuffOptionName.bite in buff_option: allowed_buffs.append(item_table[Buff.bite_rate]) - if OptionName.buff_fish_trap in buff_option: + if BuffOptionName.fish_trap in buff_option: allowed_buffs.append(item_table[Buff.fish_trap]) - if OptionName.buff_fishing_bar in buff_option: + if BuffOptionName.fishing_bar in buff_option: allowed_buffs.append(item_table[Buff.fishing_bar]) - if OptionName.buff_quality in buff_option: + if BuffOptionName.quality in buff_option: allowed_buffs.append(item_table[Buff.quality]) - if OptionName.buff_glow in buff_option: + if BuffOptionName.glow in buff_option: allowed_buffs.append(item_table[Buff.glow]) return allowed_buffs diff --git a/worlds/stardew_valley/logic/walnut_logic.py b/worlds/stardew_valley/logic/walnut_logic.py index 14fe1c339090..4ab3b46f70d9 100644 --- a/worlds/stardew_valley/logic/walnut_logic.py +++ b/worlds/stardew_valley/logic/walnut_logic.py @@ -7,10 +7,10 @@ from .has_logic import HasLogicMixin from .received_logic import ReceivedLogicMixin from .region_logic import RegionLogicMixin -from ..strings.ap_names.event_names import Event from ..options import ExcludeGingerIsland, Walnutsanity from ..stardew_rule import StardewRule, False_, True_ -from ..strings.ap_names.ap_option_names import OptionName +from ..strings.ap_names.ap_option_names import WalnutsanityOptionName +from ..strings.ap_names.event_names import Event from ..strings.craftable_names import Furniture from ..strings.crop_names import Fruit from ..strings.metal_names import Mineral, Fossil @@ -25,7 +25,7 @@ def __init__(self, *args, **kwargs): class WalnutLogic(BaseLogic[Union[WalnutLogicMixin, ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, CombatLogicMixin, - AbilityLogicMixin]]): +AbilityLogicMixin]]): def has_walnut(self, number: int) -> StardewRule: if self.options.exclude_ginger_island == ExcludeGingerIsland.option_true: @@ -44,22 +44,22 @@ def has_walnut(self, number: int) -> StardewRule: total_walnuts = puzzle_walnuts + bush_walnuts + dig_walnuts + repeatable_walnuts walnuts_to_receive = 0 walnuts_to_collect = number - if OptionName.walnutsanity_puzzles in self.options.walnutsanity: + if WalnutsanityOptionName.puzzles in self.options.walnutsanity: puzzle_walnut_rate = puzzle_walnuts / total_walnuts puzzle_walnuts_required = round(puzzle_walnut_rate * number) walnuts_to_receive += puzzle_walnuts_required walnuts_to_collect -= puzzle_walnuts_required - if OptionName.walnutsanity_bushes in self.options.walnutsanity: + if WalnutsanityOptionName.bushes in self.options.walnutsanity: bush_walnuts_rate = bush_walnuts / total_walnuts bush_walnuts_required = round(bush_walnuts_rate * number) walnuts_to_receive += bush_walnuts_required walnuts_to_collect -= bush_walnuts_required - if OptionName.walnutsanity_dig_spots in self.options.walnutsanity: + if WalnutsanityOptionName.dig_spots in self.options.walnutsanity: dig_walnuts_rate = dig_walnuts / total_walnuts dig_walnuts_required = round(dig_walnuts_rate * number) walnuts_to_receive += dig_walnuts_required walnuts_to_collect -= dig_walnuts_required - if OptionName.walnutsanity_repeatables in self.options.walnutsanity: + if WalnutsanityOptionName.repeatables in self.options.walnutsanity: repeatable_walnuts_rate = repeatable_walnuts / total_walnuts repeatable_walnuts_required = round(repeatable_walnuts_rate * number) walnuts_to_receive += repeatable_walnuts_required @@ -104,9 +104,9 @@ def can_get_walnuts(self, number: int) -> StardewRule: return reach_entire_island gems = (Mineral.amethyst, Mineral.aquamarine, Mineral.emerald, Mineral.ruby, Mineral.topaz) return reach_entire_island & self.logic.has(Fruit.banana) & self.logic.has_all(*gems) & \ - self.logic.ability.can_mine_perfectly() & self.logic.ability.can_fish_perfectly() & \ - self.logic.has(Furniture.flute_block) & self.logic.has(Seed.melon) & self.logic.has(Seed.wheat) & \ - self.logic.has(Seed.garlic) & self.can_complete_field_office() + self.logic.ability.can_mine_perfectly() & self.logic.ability.can_fish_perfectly() & \ + self.logic.has(Furniture.flute_block) & self.logic.has(Seed.melon) & self.logic.has(Seed.wheat) & \ + self.logic.has(Seed.garlic) & self.can_complete_field_office() @cached_property def can_start_field_office(self) -> StardewRule: @@ -132,4 +132,4 @@ def can_complete_bat_collection(self) -> StardewRule: def can_complete_field_office(self) -> StardewRule: return self.can_complete_large_animal_collection() & self.can_complete_snake_collection() & \ - self.can_complete_frog_collection() & self.can_complete_bat_collection() + self.can_complete_frog_collection() & self.can_complete_bat_collection() diff --git a/worlds/stardew_valley/option_groups.py b/worlds/stardew_valley/option_groups.py deleted file mode 100644 index d0f052348a7e..000000000000 --- a/worlds/stardew_valley/option_groups.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging - -from Options import DeathLink, ProgressionBalancing, Accessibility -from .options import (Goal, StartingMoney, ProfitMargin, BundleRandomization, BundlePrice, - EntranceRandomization, SeasonRandomization, Cropsanity, BackpackProgression, - ToolProgression, ElevatorProgression, SkillProgression, BuildingProgression, - FestivalLocations, ArcadeMachineLocations, SpecialOrderLocations, - QuestLocations, Fishsanity, Museumsanity, Friendsanity, FriendsanityHeartSize, - NumberOfMovementBuffs, EnabledFillerBuffs, ExcludeGingerIsland, TrapItems, - MultipleDaySleepEnabled, MultipleDaySleepCost, ExperienceMultiplier, - FriendshipMultiplier, DebrisMultiplier, QuickStart, Gifting, FarmType, - Monstersanity, Shipsanity, Cooksanity, Chefsanity, Craftsanity, Mods, Booksanity, Walnutsanity, BundlePlando) - -sv_option_groups = [] -try: - from Options import OptionGroup -except: - logging.warning("Old AP Version, OptionGroup not available.") -else: - sv_option_groups = [ - OptionGroup("General", [ - Goal, - FarmType, - BundleRandomization, - BundlePrice, - EntranceRandomization, - ExcludeGingerIsland, - ]), - OptionGroup("Major Unlocks", [ - SeasonRandomization, - Cropsanity, - BackpackProgression, - ToolProgression, - ElevatorProgression, - SkillProgression, - BuildingProgression, - ]), - OptionGroup("Extra Shuffling", [ - FestivalLocations, - ArcadeMachineLocations, - SpecialOrderLocations, - QuestLocations, - Fishsanity, - Museumsanity, - Friendsanity, - FriendsanityHeartSize, - Monstersanity, - Shipsanity, - Cooksanity, - Chefsanity, - Craftsanity, - Booksanity, - Walnutsanity, - ]), - OptionGroup("Multipliers and Buffs", [ - StartingMoney, - ProfitMargin, - ExperienceMultiplier, - FriendshipMultiplier, - DebrisMultiplier, - NumberOfMovementBuffs, - EnabledFillerBuffs, - TrapItems, - MultipleDaySleepEnabled, - MultipleDaySleepCost, - QuickStart, - ]), - OptionGroup("Advanced Options", [ - Gifting, - DeathLink, - Mods, - BundlePlando, - ProgressionBalancing, - Accessibility, - ]), - ] diff --git a/worlds/stardew_valley/options/__init__.py b/worlds/stardew_valley/options/__init__.py new file mode 100644 index 000000000000..d1436b00dff7 --- /dev/null +++ b/worlds/stardew_valley/options/__init__.py @@ -0,0 +1,6 @@ +from .options import StardewValleyOption, Goal, FarmType, StartingMoney, ProfitMargin, BundleRandomization, BundlePrice, EntranceRandomization, \ + SeasonRandomization, Cropsanity, BackpackProgression, ToolProgression, ElevatorProgression, SkillProgression, BuildingProgression, FestivalLocations, \ + ArcadeMachineLocations, SpecialOrderLocations, QuestLocations, Fishsanity, Museumsanity, Monstersanity, Shipsanity, Cooksanity, Chefsanity, Craftsanity, \ + Friendsanity, FriendsanityHeartSize, Booksanity, Walnutsanity, NumberOfMovementBuffs, EnabledFillerBuffs, ExcludeGingerIsland, TrapItems, \ + MultipleDaySleepEnabled, MultipleDaySleepCost, ExperienceMultiplier, FriendshipMultiplier, DebrisMultiplier, QuickStart, Gifting, Mods, BundlePlando, \ + StardewValleyOptions diff --git a/worlds/stardew_valley/options/forced_options.py b/worlds/stardew_valley/options/forced_options.py new file mode 100644 index 000000000000..84cdc936b3f1 --- /dev/null +++ b/worlds/stardew_valley/options/forced_options.py @@ -0,0 +1,48 @@ +import logging + +import Options as ap_options +from . import options + +logger = logging.getLogger(__name__) + + +def force_change_options_if_incompatible(world_options: options.StardewValleyOptions, player: int, player_name: str) -> None: + force_ginger_island_inclusion_when_goal_is_ginger_island_related(world_options, player, player_name) + force_walnutsanity_deactivation_when_ginger_island_is_excluded(world_options, player, player_name) + force_accessibility_to_full_when_goal_requires_all_locations(player, player_name, world_options) + + +def force_ginger_island_inclusion_when_goal_is_ginger_island_related(world_options: options.StardewValleyOptions, player: int, player_name: str) -> None: + goal_is_walnut_hunter = world_options.goal == options.Goal.option_greatest_walnut_hunter + goal_is_perfection = world_options.goal == options.Goal.option_perfection + goal_is_island_related = goal_is_walnut_hunter or goal_is_perfection + ginger_island_is_excluded = world_options.exclude_ginger_island == options.ExcludeGingerIsland.option_true + + if goal_is_island_related and ginger_island_is_excluded: + world_options.exclude_ginger_island.value = options.ExcludeGingerIsland.option_false + goal_name = world_options.goal.current_option_name + logger.warning(f"Goal '{goal_name}' requires Ginger Island. " + f"Exclude Ginger Island option forced to 'False' for player {player} ({player_name})") + + +def force_walnutsanity_deactivation_when_ginger_island_is_excluded(world_options: options.StardewValleyOptions, player: int, player_name: str): + ginger_island_is_excluded = world_options.exclude_ginger_island == options.ExcludeGingerIsland.option_true + walnutsanity_is_active = world_options.walnutsanity != options.Walnutsanity.preset_none + + if ginger_island_is_excluded and walnutsanity_is_active: + world_options.walnutsanity.value = options.Walnutsanity.preset_none + logger.warning(f"Walnutsanity requires Ginger Island. " + f"Ginger Island was excluded from {player} ({player_name})'s world, so walnutsanity was force disabled") + + +def force_accessibility_to_full_when_goal_requires_all_locations(player, player_name, world_options): + goal_is_allsanity = world_options.goal == options.Goal.option_allsanity + goal_is_perfection = world_options.goal == options.Goal.option_perfection + goal_requires_all_locations = goal_is_allsanity or goal_is_perfection + accessibility_is_minimal = world_options.accessibility == ap_options.Accessibility.option_minimal + + if goal_requires_all_locations and accessibility_is_minimal: + world_options.accessibility.value = ap_options.Accessibility.option_full + goal_name = world_options.goal.current_option_name + logger.warning(f"Goal '{goal_name}' requires full accessibility. " + f"Accessibility option forced to 'Full' for player {player} ({player_name})") diff --git a/worlds/stardew_valley/options/option_groups.py b/worlds/stardew_valley/options/option_groups.py new file mode 100644 index 000000000000..bcb9bee77ff4 --- /dev/null +++ b/worlds/stardew_valley/options/option_groups.py @@ -0,0 +1,68 @@ +import logging + +import Options as ap_options +from . import options + +sv_option_groups = [] +try: + from Options import OptionGroup +except ImportError: + logging.warning("Old AP Version, OptionGroup not available.") +else: + sv_option_groups = [ + OptionGroup("General", [ + options.Goal, + options.FarmType, + options.BundleRandomization, + options.BundlePrice, + options.EntranceRandomization, + options.ExcludeGingerIsland, + ]), + OptionGroup("Major Unlocks", [ + options.SeasonRandomization, + options.Cropsanity, + options.BackpackProgression, + options.ToolProgression, + options.ElevatorProgression, + options.SkillProgression, + options.BuildingProgression, + ]), + OptionGroup("Extra Shuffling", [ + options.FestivalLocations, + options.ArcadeMachineLocations, + options.SpecialOrderLocations, + options.QuestLocations, + options.Fishsanity, + options.Museumsanity, + options.Friendsanity, + options.FriendsanityHeartSize, + options.Monstersanity, + options.Shipsanity, + options.Cooksanity, + options.Chefsanity, + options.Craftsanity, + options.Booksanity, + options.Walnutsanity, + ]), + OptionGroup("Multipliers and Buffs", [ + options.StartingMoney, + options.ProfitMargin, + options.ExperienceMultiplier, + options.FriendshipMultiplier, + options.DebrisMultiplier, + options.NumberOfMovementBuffs, + options.EnabledFillerBuffs, + options.TrapItems, + options.MultipleDaySleepEnabled, + options.MultipleDaySleepCost, + options.QuickStart, + ]), + OptionGroup("Advanced Options", [ + options.Gifting, + ap_options.DeathLink, + options.Mods, + options.BundlePlando, + ap_options.ProgressionBalancing, + ap_options.Accessibility, + ]), + ] diff --git a/worlds/stardew_valley/options.py b/worlds/stardew_valley/options/options.py similarity index 97% rename from worlds/stardew_valley/options.py rename to worlds/stardew_valley/options/options.py index 5369e88a2dcb..5d3b25b4da13 100644 --- a/worlds/stardew_valley/options.py +++ b/worlds/stardew_valley/options/options.py @@ -4,9 +4,9 @@ from typing import Protocol, ClassVar from Options import Range, NamedRange, Toggle, Choice, OptionSet, PerGameCommonOptions, DeathLink, OptionList, Visibility -from .mods.mod_data import ModNames -from .strings.ap_names.ap_option_names import OptionName -from .strings.bundle_names import all_cc_bundle_names +from ..mods.mod_data import ModNames +from ..strings.ap_names.ap_option_names import BuffOptionName, WalnutsanityOptionName +from ..strings.bundle_names import all_cc_bundle_names class StardewValleyOption(Protocol): @@ -582,8 +582,10 @@ class Walnutsanity(OptionSet): """ internal_name = "walnutsanity" display_name = "Walnutsanity" - valid_keys = frozenset({OptionName.walnutsanity_puzzles, OptionName.walnutsanity_bushes, OptionName.walnutsanity_dig_spots, - OptionName.walnutsanity_repeatables, }) + valid_keys = frozenset({ + WalnutsanityOptionName.puzzles, WalnutsanityOptionName.bushes, WalnutsanityOptionName.dig_spots, + WalnutsanityOptionName.repeatables, + }) preset_none = frozenset() preset_all = valid_keys default = preset_none @@ -622,12 +624,14 @@ class EnabledFillerBuffs(OptionSet): """ internal_name = "enabled_filler_buffs" display_name = "Enabled Filler Buffs" - valid_keys = frozenset({OptionName.buff_luck, OptionName.buff_damage, OptionName.buff_defense, OptionName.buff_immunity, OptionName.buff_health, - OptionName.buff_energy, OptionName.buff_bite, OptionName.buff_fish_trap, OptionName.buff_fishing_bar}) - # OptionName.buff_quality, OptionName.buff_glow}) # Disabled these two buffs because they are too hard to make on the mod side + valid_keys = frozenset({ + BuffOptionName.luck, BuffOptionName.damage, BuffOptionName.defense, BuffOptionName.immunity, BuffOptionName.health, + BuffOptionName.energy, BuffOptionName.bite, BuffOptionName.fish_trap, BuffOptionName.fishing_bar, + }) + # OptionName.buff_quality, OptionName.buff_glow}) # Disabled these two buffs because they are too hard to make on the mod side preset_none = frozenset() preset_all = valid_keys - default = frozenset({OptionName.buff_luck, OptionName.buff_defense, OptionName.buff_bite}) + default = frozenset({BuffOptionName.luck, BuffOptionName.defense, BuffOptionName.bite}) class ExcludeGingerIsland(Toggle): @@ -762,7 +766,6 @@ class Gifting(Toggle): ModNames.wellwick, ModNames.shiko, ModNames.delores, ModNames.riley, ModNames.boarding_house} - if 'unittest' in sys.modules.keys() or 'pytest' in sys.modules.keys(): disabled_mods = {} diff --git a/worlds/stardew_valley/options/presets.py b/worlds/stardew_valley/options/presets.py new file mode 100644 index 000000000000..c2c210e5ca6e --- /dev/null +++ b/worlds/stardew_valley/options/presets.py @@ -0,0 +1,371 @@ +from typing import Any, Dict + +import Options as ap_options +from . import options +from ..strings.ap_names.ap_option_names import WalnutsanityOptionName + +# @formatter:off +all_random_settings = { + "progression_balancing": "random", + "accessibility": "random", + options.Goal.internal_name: "random", + options.FarmType.internal_name: "random", + options.StartingMoney.internal_name: "random", + options.ProfitMargin.internal_name: "random", + options.BundleRandomization.internal_name: "random", + options.BundlePrice.internal_name: "random", + options.EntranceRandomization.internal_name: "random", + options.SeasonRandomization.internal_name: "random", + options.Cropsanity.internal_name: "random", + options.BackpackProgression.internal_name: "random", + options.ToolProgression.internal_name: "random", + options.ElevatorProgression.internal_name: "random", + options.SkillProgression.internal_name: "random", + options.BuildingProgression.internal_name: "random", + options.FestivalLocations.internal_name: "random", + options.ArcadeMachineLocations.internal_name: "random", + options.SpecialOrderLocations.internal_name: "random", + options.QuestLocations.internal_name: "random", + options.Fishsanity.internal_name: "random", + options.Museumsanity.internal_name: "random", + options.Monstersanity.internal_name: "random", + options.Shipsanity.internal_name: "random", + options.Cooksanity.internal_name: "random", + options.Chefsanity.internal_name: "random", + options.Craftsanity.internal_name: "random", + options.Friendsanity.internal_name: "random", + options.FriendsanityHeartSize.internal_name: "random", + options.Booksanity.internal_name: "random", + options.NumberOfMovementBuffs.internal_name: "random", + options.ExcludeGingerIsland.internal_name: "random", + options.TrapItems.internal_name: "random", + options.MultipleDaySleepEnabled.internal_name: "random", + options.MultipleDaySleepCost.internal_name: "random", + options.ExperienceMultiplier.internal_name: "random", + options.FriendshipMultiplier.internal_name: "random", + options.DebrisMultiplier.internal_name: "random", + options.QuickStart.internal_name: "random", + options.Gifting.internal_name: "random", + "death_link": "random", +} + +easy_settings = { + options.Goal.internal_name: options.Goal.option_community_center, + options.FarmType.internal_name: "random", + options.StartingMoney.internal_name: "very rich", + options.ProfitMargin.internal_name: "double", + options.BundleRandomization.internal_name: options.BundleRandomization.option_thematic, + options.BundlePrice.internal_name: options.BundlePrice.option_cheap, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, + options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized_not_winter, + options.Cropsanity.internal_name: options.Cropsanity.option_enabled, + options.BackpackProgression.internal_name: options.BackpackProgression.option_early_progressive, + options.ToolProgression.internal_name: options.ToolProgression.option_progressive_very_cheap, + options.ElevatorProgression.internal_name: options.ElevatorProgression.option_progressive, + options.SkillProgression.internal_name: options.SkillProgression.option_progressive, + options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive_very_cheap, + options.FestivalLocations.internal_name: options.FestivalLocations.option_easy, + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_disabled, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_vanilla_very_short, + options.QuestLocations.internal_name: "minimum", + options.Fishsanity.internal_name: options.Fishsanity.option_only_easy_fish, + options.Museumsanity.internal_name: options.Museumsanity.option_milestones, + options.Monstersanity.internal_name: options.Monstersanity.option_one_per_category, + options.Shipsanity.internal_name: options.Shipsanity.option_none, + options.Cooksanity.internal_name: options.Cooksanity.option_none, + options.Chefsanity.internal_name: options.Chefsanity.option_none, + options.Craftsanity.internal_name: options.Craftsanity.option_none, + options.Friendsanity.internal_name: options.Friendsanity.option_none, + options.FriendsanityHeartSize.internal_name: 4, + options.Booksanity.internal_name: options.Booksanity.option_none, + options.Walnutsanity.internal_name: options.Walnutsanity.preset_none, + options.NumberOfMovementBuffs.internal_name: 8, + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, + options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, + options.TrapItems.internal_name: options.TrapItems.option_easy, + options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true, + options.MultipleDaySleepCost.internal_name: "free", + options.ExperienceMultiplier.internal_name: "triple", + options.FriendshipMultiplier.internal_name: "quadruple", + options.DebrisMultiplier.internal_name: options.DebrisMultiplier.option_quarter, + options.QuickStart.internal_name: options.QuickStart.option_true, + options.Gifting.internal_name: options.Gifting.option_true, + "death_link": "false", +} + +medium_settings = { + options.Goal.internal_name: options.Goal.option_community_center, + options.FarmType.internal_name: "random", + options.StartingMoney.internal_name: "rich", + options.ProfitMargin.internal_name: 150, + options.BundleRandomization.internal_name: options.BundleRandomization.option_remixed, + options.BundlePrice.internal_name: options.BundlePrice.option_normal, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_non_progression, + options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized, + options.Cropsanity.internal_name: options.Cropsanity.option_enabled, + options.BackpackProgression.internal_name: options.BackpackProgression.option_early_progressive, + options.ToolProgression.internal_name: options.ToolProgression.option_progressive_cheap, + options.ElevatorProgression.internal_name: options.ElevatorProgression.option_progressive, + options.SkillProgression.internal_name: options.SkillProgression.option_progressive, + options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive_cheap, + options.FestivalLocations.internal_name: options.FestivalLocations.option_hard, + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_victories_easy, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_short, + options.QuestLocations.internal_name: "normal", + options.Fishsanity.internal_name: options.Fishsanity.option_exclude_legendaries, + options.Museumsanity.internal_name: options.Museumsanity.option_milestones, + options.Monstersanity.internal_name: options.Monstersanity.option_one_per_monster, + options.Shipsanity.internal_name: options.Shipsanity.option_none, + options.Cooksanity.internal_name: options.Cooksanity.option_none, + options.Chefsanity.internal_name: options.Chefsanity.option_queen_of_sauce, + options.Craftsanity.internal_name: options.Craftsanity.option_none, + options.Friendsanity.internal_name: options.Friendsanity.option_starting_npcs, + options.FriendsanityHeartSize.internal_name: 4, + options.Booksanity.internal_name: options.Booksanity.option_power_skill, + options.Walnutsanity.internal_name: [WalnutsanityOptionName.puzzles], + options.NumberOfMovementBuffs.internal_name: 6, + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, + options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, + options.TrapItems.internal_name: options.TrapItems.option_medium, + options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true, + options.MultipleDaySleepCost.internal_name: "free", + options.ExperienceMultiplier.internal_name: "double", + options.FriendshipMultiplier.internal_name: "triple", + options.DebrisMultiplier.internal_name: options.DebrisMultiplier.option_half, + options.QuickStart.internal_name: options.QuickStart.option_true, + options.Gifting.internal_name: options.Gifting.option_true, + "death_link": "false", +} + +hard_settings = { + options.Goal.internal_name: options.Goal.option_grandpa_evaluation, + options.FarmType.internal_name: "random", + options.StartingMoney.internal_name: "extra", + options.ProfitMargin.internal_name: "normal", + options.BundleRandomization.internal_name: options.BundleRandomization.option_remixed, + options.BundlePrice.internal_name: options.BundlePrice.option_expensive, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_buildings_without_house, + options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized, + options.Cropsanity.internal_name: options.Cropsanity.option_enabled, + options.BackpackProgression.internal_name: options.BackpackProgression.option_progressive, + options.ToolProgression.internal_name: options.ToolProgression.option_progressive, + options.ElevatorProgression.internal_name: options.ElevatorProgression.option_progressive_from_previous_floor, + options.SkillProgression.internal_name: options.SkillProgression.option_progressive_with_masteries, + options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive, + options.FestivalLocations.internal_name: options.FestivalLocations.option_hard, + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_full_shuffling, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi_short, + options.QuestLocations.internal_name: "lots", + options.Fishsanity.internal_name: options.Fishsanity.option_all, + options.Museumsanity.internal_name: options.Museumsanity.option_all, + options.Monstersanity.internal_name: options.Monstersanity.option_progressive_goals, + options.Shipsanity.internal_name: options.Shipsanity.option_crops, + options.Cooksanity.internal_name: options.Cooksanity.option_queen_of_sauce, + options.Chefsanity.internal_name: options.Chefsanity.option_qos_and_purchases, + options.Craftsanity.internal_name: options.Craftsanity.option_none, + options.Friendsanity.internal_name: options.Friendsanity.option_all, + options.FriendsanityHeartSize.internal_name: 4, + options.Booksanity.internal_name: options.Booksanity.option_all, + options.Walnutsanity.internal_name: options.Walnutsanity.preset_all, + options.NumberOfMovementBuffs.internal_name: 4, + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.default, + options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, + options.TrapItems.internal_name: options.TrapItems.option_hard, + options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true, + options.MultipleDaySleepCost.internal_name: "cheap", + options.ExperienceMultiplier.internal_name: "vanilla", + options.FriendshipMultiplier.internal_name: "double", + options.DebrisMultiplier.internal_name: options.DebrisMultiplier.option_vanilla, + options.QuickStart.internal_name: options.QuickStart.option_true, + options.Gifting.internal_name: options.Gifting.option_true, + "death_link": "true", +} + +nightmare_settings = { + options.Goal.internal_name: options.Goal.option_community_center, + options.FarmType.internal_name: "random", + options.StartingMoney.internal_name: "vanilla", + options.ProfitMargin.internal_name: "half", + options.BundleRandomization.internal_name: options.BundleRandomization.option_shuffled, + options.BundlePrice.internal_name: options.BundlePrice.option_very_expensive, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_buildings, + options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized, + options.Cropsanity.internal_name: options.Cropsanity.option_enabled, + options.BackpackProgression.internal_name: options.BackpackProgression.option_progressive, + options.ToolProgression.internal_name: options.ToolProgression.option_progressive, + options.ElevatorProgression.internal_name: options.ElevatorProgression.option_progressive_from_previous_floor, + options.SkillProgression.internal_name: options.SkillProgression.option_progressive_with_masteries, + options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive, + options.FestivalLocations.internal_name: options.FestivalLocations.option_hard, + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_full_shuffling, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi, + options.QuestLocations.internal_name: "maximum", + options.Fishsanity.internal_name: options.Fishsanity.option_special, + options.Museumsanity.internal_name: options.Museumsanity.option_all, + options.Monstersanity.internal_name: options.Monstersanity.option_split_goals, + options.Shipsanity.internal_name: options.Shipsanity.option_full_shipment_with_fish, + options.Cooksanity.internal_name: options.Cooksanity.option_queen_of_sauce, + options.Chefsanity.internal_name: options.Chefsanity.option_qos_and_purchases, + options.Craftsanity.internal_name: options.Craftsanity.option_none, + options.Friendsanity.internal_name: options.Friendsanity.option_all_with_marriage, + options.FriendsanityHeartSize.internal_name: 4, + options.Booksanity.internal_name: options.Booksanity.option_all, + options.Walnutsanity.internal_name: options.Walnutsanity.preset_all, + options.NumberOfMovementBuffs.internal_name: 2, + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_none, + options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, + options.TrapItems.internal_name: options.TrapItems.option_hell, + options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true, + options.MultipleDaySleepCost.internal_name: "expensive", + options.ExperienceMultiplier.internal_name: "half", + options.FriendshipMultiplier.internal_name: "vanilla", + options.DebrisMultiplier.internal_name: options.DebrisMultiplier.option_vanilla, + options.QuickStart.internal_name: options.QuickStart.option_false, + options.Gifting.internal_name: options.Gifting.option_true, + "death_link": "true", +} + +short_settings = { + options.Goal.internal_name: options.Goal.option_bottom_of_the_mines, + options.FarmType.internal_name: "random", + options.StartingMoney.internal_name: "filthy rich", + options.ProfitMargin.internal_name: "quadruple", + options.BundleRandomization.internal_name: options.BundleRandomization.option_remixed, + options.BundlePrice.internal_name: options.BundlePrice.option_minimum, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, + options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized_not_winter, + options.Cropsanity.internal_name: options.Cropsanity.option_disabled, + options.BackpackProgression.internal_name: options.BackpackProgression.option_early_progressive, + options.ToolProgression.internal_name: options.ToolProgression.option_progressive_very_cheap, + options.ElevatorProgression.internal_name: options.ElevatorProgression.option_progressive, + options.SkillProgression.internal_name: options.SkillProgression.option_progressive, + options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive_very_cheap, + options.FestivalLocations.internal_name: options.FestivalLocations.option_disabled, + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_disabled, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_vanilla_very_short, + options.QuestLocations.internal_name: "none", + options.Fishsanity.internal_name: options.Fishsanity.option_none, + options.Museumsanity.internal_name: options.Museumsanity.option_none, + options.Monstersanity.internal_name: options.Monstersanity.option_none, + options.Shipsanity.internal_name: options.Shipsanity.option_none, + options.Cooksanity.internal_name: options.Cooksanity.option_none, + options.Chefsanity.internal_name: options.Chefsanity.option_none, + options.Craftsanity.internal_name: options.Craftsanity.option_none, + options.Friendsanity.internal_name: options.Friendsanity.option_none, + options.FriendsanityHeartSize.internal_name: 4, + options.Booksanity.internal_name: options.Booksanity.option_none, + options.Walnutsanity.internal_name: options.Walnutsanity.preset_none, + options.NumberOfMovementBuffs.internal_name: 10, + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, + options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, + options.TrapItems.internal_name: options.TrapItems.option_easy, + options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true, + options.MultipleDaySleepCost.internal_name: "free", + options.ExperienceMultiplier.internal_name: "quadruple", + options.FriendshipMultiplier.internal_name: 800, + options.DebrisMultiplier.internal_name: options.DebrisMultiplier.option_none, + options.QuickStart.internal_name: options.QuickStart.option_true, + options.Gifting.internal_name: options.Gifting.option_true, + "death_link": "false", +} + +minsanity_settings = { + options.Goal.internal_name: options.Goal.default, + options.FarmType.internal_name: "random", + options.StartingMoney.internal_name: options.StartingMoney.default, + options.ProfitMargin.internal_name: options.ProfitMargin.default, + options.BundleRandomization.internal_name: options.BundleRandomization.default, + options.BundlePrice.internal_name: options.BundlePrice.default, + options.EntranceRandomization.internal_name: options.EntranceRandomization.default, + options.SeasonRandomization.internal_name: options.SeasonRandomization.option_disabled, + options.Cropsanity.internal_name: options.Cropsanity.option_disabled, + options.BackpackProgression.internal_name: options.BackpackProgression.option_vanilla, + options.ToolProgression.internal_name: options.ToolProgression.option_vanilla, + options.ElevatorProgression.internal_name: options.ElevatorProgression.option_vanilla, + options.SkillProgression.internal_name: options.SkillProgression.option_vanilla, + options.BuildingProgression.internal_name: options.BuildingProgression.option_vanilla, + options.FestivalLocations.internal_name: options.FestivalLocations.option_disabled, + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_disabled, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_vanilla_very_short, + options.QuestLocations.internal_name: "none", + options.Fishsanity.internal_name: options.Fishsanity.option_none, + options.Museumsanity.internal_name: options.Museumsanity.option_none, + options.Monstersanity.internal_name: options.Monstersanity.option_none, + options.Shipsanity.internal_name: options.Shipsanity.option_none, + options.Cooksanity.internal_name: options.Cooksanity.option_none, + options.Chefsanity.internal_name: options.Chefsanity.option_none, + options.Craftsanity.internal_name: options.Craftsanity.option_none, + options.Friendsanity.internal_name: options.Friendsanity.option_none, + options.FriendsanityHeartSize.internal_name: options.FriendsanityHeartSize.default, + options.Booksanity.internal_name: options.Booksanity.option_none, + options.Walnutsanity.internal_name: options.Walnutsanity.preset_none, + options.NumberOfMovementBuffs.internal_name: options.NumberOfMovementBuffs.default, + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.default, + options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, + options.TrapItems.internal_name: options.TrapItems.default, + options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.default, + options.MultipleDaySleepCost.internal_name: options.MultipleDaySleepCost.default, + options.ExperienceMultiplier.internal_name: options.ExperienceMultiplier.default, + options.FriendshipMultiplier.internal_name: options.FriendshipMultiplier.default, + options.DebrisMultiplier.internal_name: options.DebrisMultiplier.default, + options.QuickStart.internal_name: options.QuickStart.default, + options.Gifting.internal_name: options.Gifting.default, + "death_link": ap_options.DeathLink.default, +} + +allsanity_settings = { + options.Goal.internal_name: options.Goal.default, + options.FarmType.internal_name: "random", + options.StartingMoney.internal_name: options.StartingMoney.default, + options.ProfitMargin.internal_name: options.ProfitMargin.default, + options.BundleRandomization.internal_name: options.BundleRandomization.default, + options.BundlePrice.internal_name: options.BundlePrice.default, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_buildings, + options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized, + options.Cropsanity.internal_name: options.Cropsanity.option_enabled, + options.BackpackProgression.internal_name: options.BackpackProgression.option_early_progressive, + options.ToolProgression.internal_name: options.ToolProgression.option_progressive, + options.ElevatorProgression.internal_name: options.ElevatorProgression.option_progressive, + options.SkillProgression.internal_name: options.SkillProgression.option_progressive_with_masteries, + options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive, + options.FestivalLocations.internal_name: options.FestivalLocations.option_hard, + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_full_shuffling, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi, + options.QuestLocations.internal_name: "maximum", + options.Fishsanity.internal_name: options.Fishsanity.option_all, + options.Museumsanity.internal_name: options.Museumsanity.option_all, + options.Monstersanity.internal_name: options.Monstersanity.option_progressive_goals, + options.Shipsanity.internal_name: options.Shipsanity.option_everything, + options.Cooksanity.internal_name: options.Cooksanity.option_all, + options.Chefsanity.internal_name: options.Chefsanity.option_all, + options.Craftsanity.internal_name: options.Craftsanity.option_all, + options.Friendsanity.internal_name: options.Friendsanity.option_all, + options.FriendsanityHeartSize.internal_name: 1, + options.Booksanity.internal_name: options.Booksanity.option_all, + options.Walnutsanity.internal_name: options.Walnutsanity.preset_all, + options.NumberOfMovementBuffs.internal_name: 12, + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, + options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, + options.TrapItems.internal_name: options.TrapItems.default, + options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.default, + options.MultipleDaySleepCost.internal_name: options.MultipleDaySleepCost.default, + options.ExperienceMultiplier.internal_name: options.ExperienceMultiplier.default, + options.FriendshipMultiplier.internal_name: options.FriendshipMultiplier.default, + options.DebrisMultiplier.internal_name: options.DebrisMultiplier.default, + options.QuickStart.internal_name: options.QuickStart.default, + options.Gifting.internal_name: options.Gifting.default, + "death_link": ap_options.DeathLink.default, +} +# @formatter:on + + +sv_options_presets: Dict[str, Dict[str, Any]] = { + "All random": all_random_settings, + "Easy": easy_settings, + "Medium": medium_settings, + "Hard": hard_settings, + "Nightmare": nightmare_settings, + "Short": short_settings, + "Minsanity": minsanity_settings, + "Allsanity": allsanity_settings, +} diff --git a/worlds/stardew_valley/presets.py b/worlds/stardew_valley/presets.py deleted file mode 100644 index 62672f29e424..000000000000 --- a/worlds/stardew_valley/presets.py +++ /dev/null @@ -1,376 +0,0 @@ -from typing import Any, Dict - -from Options import Accessibility, ProgressionBalancing, DeathLink -from .options import Goal, StartingMoney, ProfitMargin, BundleRandomization, BundlePrice, EntranceRandomization, SeasonRandomization, Cropsanity, \ - BackpackProgression, ToolProgression, ElevatorProgression, SkillProgression, BuildingProgression, FestivalLocations, ArcadeMachineLocations, \ - SpecialOrderLocations, QuestLocations, Fishsanity, Museumsanity, Friendsanity, FriendsanityHeartSize, NumberOfMovementBuffs, ExcludeGingerIsland, TrapItems, \ - MultipleDaySleepEnabled, MultipleDaySleepCost, ExperienceMultiplier, FriendshipMultiplier, DebrisMultiplier, QuickStart, \ - Gifting, FarmType, Monstersanity, Shipsanity, Cooksanity, Chefsanity, Craftsanity, Booksanity, Walnutsanity, EnabledFillerBuffs - -# @formatter:off -from .strings.ap_names.ap_option_names import OptionName - -all_random_settings = { - "progression_balancing": "random", - "accessibility": "random", - Goal.internal_name: "random", - FarmType.internal_name: "random", - StartingMoney.internal_name: "random", - ProfitMargin.internal_name: "random", - BundleRandomization.internal_name: "random", - BundlePrice.internal_name: "random", - EntranceRandomization.internal_name: "random", - SeasonRandomization.internal_name: "random", - Cropsanity.internal_name: "random", - BackpackProgression.internal_name: "random", - ToolProgression.internal_name: "random", - ElevatorProgression.internal_name: "random", - SkillProgression.internal_name: "random", - BuildingProgression.internal_name: "random", - FestivalLocations.internal_name: "random", - ArcadeMachineLocations.internal_name: "random", - SpecialOrderLocations.internal_name: "random", - QuestLocations.internal_name: "random", - Fishsanity.internal_name: "random", - Museumsanity.internal_name: "random", - Monstersanity.internal_name: "random", - Shipsanity.internal_name: "random", - Cooksanity.internal_name: "random", - Chefsanity.internal_name: "random", - Craftsanity.internal_name: "random", - Friendsanity.internal_name: "random", - FriendsanityHeartSize.internal_name: "random", - Booksanity.internal_name: "random", - NumberOfMovementBuffs.internal_name: "random", - ExcludeGingerIsland.internal_name: "random", - TrapItems.internal_name: "random", - MultipleDaySleepEnabled.internal_name: "random", - MultipleDaySleepCost.internal_name: "random", - ExperienceMultiplier.internal_name: "random", - FriendshipMultiplier.internal_name: "random", - DebrisMultiplier.internal_name: "random", - QuickStart.internal_name: "random", - Gifting.internal_name: "random", - "death_link": "random", -} - -easy_settings = { - Goal.internal_name: Goal.option_community_center, - FarmType.internal_name: "random", - StartingMoney.internal_name: "very rich", - ProfitMargin.internal_name: "double", - BundleRandomization.internal_name: BundleRandomization.option_thematic, - BundlePrice.internal_name: BundlePrice.option_cheap, - EntranceRandomization.internal_name: EntranceRandomization.option_disabled, - SeasonRandomization.internal_name: SeasonRandomization.option_randomized_not_winter, - Cropsanity.internal_name: Cropsanity.option_enabled, - BackpackProgression.internal_name: BackpackProgression.option_early_progressive, - ToolProgression.internal_name: ToolProgression.option_progressive_very_cheap, - ElevatorProgression.internal_name: ElevatorProgression.option_progressive, - SkillProgression.internal_name: SkillProgression.option_progressive, - BuildingProgression.internal_name: BuildingProgression.option_progressive_very_cheap, - FestivalLocations.internal_name: FestivalLocations.option_easy, - ArcadeMachineLocations.internal_name: ArcadeMachineLocations.option_disabled, - SpecialOrderLocations.internal_name: SpecialOrderLocations.option_vanilla_very_short, - QuestLocations.internal_name: "minimum", - Fishsanity.internal_name: Fishsanity.option_only_easy_fish, - Museumsanity.internal_name: Museumsanity.option_milestones, - Monstersanity.internal_name: Monstersanity.option_one_per_category, - Shipsanity.internal_name: Shipsanity.option_none, - Cooksanity.internal_name: Cooksanity.option_none, - Chefsanity.internal_name: Chefsanity.option_none, - Craftsanity.internal_name: Craftsanity.option_none, - Friendsanity.internal_name: Friendsanity.option_none, - FriendsanityHeartSize.internal_name: 4, - Booksanity.internal_name: Booksanity.option_none, - Walnutsanity.internal_name: Walnutsanity.preset_none, - NumberOfMovementBuffs.internal_name: 8, - EnabledFillerBuffs.internal_name: EnabledFillerBuffs.preset_all, - ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_true, - TrapItems.internal_name: TrapItems.option_easy, - MultipleDaySleepEnabled.internal_name: MultipleDaySleepEnabled.option_true, - MultipleDaySleepCost.internal_name: "free", - ExperienceMultiplier.internal_name: "triple", - FriendshipMultiplier.internal_name: "quadruple", - DebrisMultiplier.internal_name: DebrisMultiplier.option_quarter, - QuickStart.internal_name: QuickStart.option_true, - Gifting.internal_name: Gifting.option_true, - "death_link": "false", -} - -medium_settings = { - Goal.internal_name: Goal.option_community_center, - FarmType.internal_name: "random", - StartingMoney.internal_name: "rich", - ProfitMargin.internal_name: 150, - BundleRandomization.internal_name: BundleRandomization.option_remixed, - BundlePrice.internal_name: BundlePrice.option_normal, - EntranceRandomization.internal_name: EntranceRandomization.option_non_progression, - SeasonRandomization.internal_name: SeasonRandomization.option_randomized, - Cropsanity.internal_name: Cropsanity.option_enabled, - BackpackProgression.internal_name: BackpackProgression.option_early_progressive, - ToolProgression.internal_name: ToolProgression.option_progressive_cheap, - ElevatorProgression.internal_name: ElevatorProgression.option_progressive, - SkillProgression.internal_name: SkillProgression.option_progressive, - BuildingProgression.internal_name: BuildingProgression.option_progressive_cheap, - FestivalLocations.internal_name: FestivalLocations.option_hard, - ArcadeMachineLocations.internal_name: ArcadeMachineLocations.option_victories_easy, - SpecialOrderLocations.internal_name: SpecialOrderLocations.option_board_short, - QuestLocations.internal_name: "normal", - Fishsanity.internal_name: Fishsanity.option_exclude_legendaries, - Museumsanity.internal_name: Museumsanity.option_milestones, - Monstersanity.internal_name: Monstersanity.option_one_per_monster, - Shipsanity.internal_name: Shipsanity.option_none, - Cooksanity.internal_name: Cooksanity.option_none, - Chefsanity.internal_name: Chefsanity.option_queen_of_sauce, - Craftsanity.internal_name: Craftsanity.option_none, - Friendsanity.internal_name: Friendsanity.option_starting_npcs, - FriendsanityHeartSize.internal_name: 4, - Booksanity.internal_name: Booksanity.option_power_skill, - Walnutsanity.internal_name: [OptionName.walnutsanity_puzzles], - NumberOfMovementBuffs.internal_name: 6, - EnabledFillerBuffs.internal_name: EnabledFillerBuffs.preset_all, - ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_true, - TrapItems.internal_name: TrapItems.option_medium, - MultipleDaySleepEnabled.internal_name: MultipleDaySleepEnabled.option_true, - MultipleDaySleepCost.internal_name: "free", - ExperienceMultiplier.internal_name: "double", - FriendshipMultiplier.internal_name: "triple", - DebrisMultiplier.internal_name: DebrisMultiplier.option_half, - QuickStart.internal_name: QuickStart.option_true, - Gifting.internal_name: Gifting.option_true, - "death_link": "false", -} - -hard_settings = { - Goal.internal_name: Goal.option_grandpa_evaluation, - FarmType.internal_name: "random", - StartingMoney.internal_name: "extra", - ProfitMargin.internal_name: "normal", - BundleRandomization.internal_name: BundleRandomization.option_remixed, - BundlePrice.internal_name: BundlePrice.option_expensive, - EntranceRandomization.internal_name: EntranceRandomization.option_buildings_without_house, - SeasonRandomization.internal_name: SeasonRandomization.option_randomized, - Cropsanity.internal_name: Cropsanity.option_enabled, - BackpackProgression.internal_name: BackpackProgression.option_progressive, - ToolProgression.internal_name: ToolProgression.option_progressive, - ElevatorProgression.internal_name: ElevatorProgression.option_progressive_from_previous_floor, - SkillProgression.internal_name: SkillProgression.option_progressive_with_masteries, - BuildingProgression.internal_name: BuildingProgression.option_progressive, - FestivalLocations.internal_name: FestivalLocations.option_hard, - ArcadeMachineLocations.internal_name: ArcadeMachineLocations.option_full_shuffling, - SpecialOrderLocations.internal_name: SpecialOrderLocations.option_board_qi_short, - QuestLocations.internal_name: "lots", - Fishsanity.internal_name: Fishsanity.option_all, - Museumsanity.internal_name: Museumsanity.option_all, - Monstersanity.internal_name: Monstersanity.option_progressive_goals, - Shipsanity.internal_name: Shipsanity.option_crops, - Cooksanity.internal_name: Cooksanity.option_queen_of_sauce, - Chefsanity.internal_name: Chefsanity.option_qos_and_purchases, - Craftsanity.internal_name: Craftsanity.option_none, - Friendsanity.internal_name: Friendsanity.option_all, - FriendsanityHeartSize.internal_name: 4, - Booksanity.internal_name: Booksanity.option_all, - Walnutsanity.internal_name: Walnutsanity.preset_all, - NumberOfMovementBuffs.internal_name: 4, - EnabledFillerBuffs.internal_name: EnabledFillerBuffs.default, - ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_false, - TrapItems.internal_name: TrapItems.option_hard, - MultipleDaySleepEnabled.internal_name: MultipleDaySleepEnabled.option_true, - MultipleDaySleepCost.internal_name: "cheap", - ExperienceMultiplier.internal_name: "vanilla", - FriendshipMultiplier.internal_name: "double", - DebrisMultiplier.internal_name: DebrisMultiplier.option_vanilla, - QuickStart.internal_name: QuickStart.option_true, - Gifting.internal_name: Gifting.option_true, - "death_link": "true", -} - -nightmare_settings = { - Goal.internal_name: Goal.option_community_center, - FarmType.internal_name: "random", - StartingMoney.internal_name: "vanilla", - ProfitMargin.internal_name: "half", - BundleRandomization.internal_name: BundleRandomization.option_shuffled, - BundlePrice.internal_name: BundlePrice.option_very_expensive, - EntranceRandomization.internal_name: EntranceRandomization.option_buildings, - SeasonRandomization.internal_name: SeasonRandomization.option_randomized, - Cropsanity.internal_name: Cropsanity.option_enabled, - BackpackProgression.internal_name: BackpackProgression.option_progressive, - ToolProgression.internal_name: ToolProgression.option_progressive, - ElevatorProgression.internal_name: ElevatorProgression.option_progressive_from_previous_floor, - SkillProgression.internal_name: SkillProgression.option_progressive_with_masteries, - BuildingProgression.internal_name: BuildingProgression.option_progressive, - FestivalLocations.internal_name: FestivalLocations.option_hard, - ArcadeMachineLocations.internal_name: ArcadeMachineLocations.option_full_shuffling, - SpecialOrderLocations.internal_name: SpecialOrderLocations.option_board_qi, - QuestLocations.internal_name: "maximum", - Fishsanity.internal_name: Fishsanity.option_special, - Museumsanity.internal_name: Museumsanity.option_all, - Monstersanity.internal_name: Monstersanity.option_split_goals, - Shipsanity.internal_name: Shipsanity.option_full_shipment_with_fish, - Cooksanity.internal_name: Cooksanity.option_queen_of_sauce, - Chefsanity.internal_name: Chefsanity.option_qos_and_purchases, - Craftsanity.internal_name: Craftsanity.option_none, - Friendsanity.internal_name: Friendsanity.option_all_with_marriage, - FriendsanityHeartSize.internal_name: 4, - Booksanity.internal_name: Booksanity.option_all, - Walnutsanity.internal_name: Walnutsanity.preset_all, - NumberOfMovementBuffs.internal_name: 2, - EnabledFillerBuffs.internal_name: EnabledFillerBuffs.preset_none, - ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_false, - TrapItems.internal_name: TrapItems.option_hell, - MultipleDaySleepEnabled.internal_name: MultipleDaySleepEnabled.option_true, - MultipleDaySleepCost.internal_name: "expensive", - ExperienceMultiplier.internal_name: "half", - FriendshipMultiplier.internal_name: "vanilla", - DebrisMultiplier.internal_name: DebrisMultiplier.option_vanilla, - QuickStart.internal_name: QuickStart.option_false, - Gifting.internal_name: Gifting.option_true, - "death_link": "true", -} - -short_settings = { - Goal.internal_name: Goal.option_bottom_of_the_mines, - FarmType.internal_name: "random", - StartingMoney.internal_name: "filthy rich", - ProfitMargin.internal_name: "quadruple", - BundleRandomization.internal_name: BundleRandomization.option_remixed, - BundlePrice.internal_name: BundlePrice.option_minimum, - EntranceRandomization.internal_name: EntranceRandomization.option_disabled, - SeasonRandomization.internal_name: SeasonRandomization.option_randomized_not_winter, - Cropsanity.internal_name: Cropsanity.option_disabled, - BackpackProgression.internal_name: BackpackProgression.option_early_progressive, - ToolProgression.internal_name: ToolProgression.option_progressive_very_cheap, - ElevatorProgression.internal_name: ElevatorProgression.option_progressive, - SkillProgression.internal_name: SkillProgression.option_progressive, - BuildingProgression.internal_name: BuildingProgression.option_progressive_very_cheap, - FestivalLocations.internal_name: FestivalLocations.option_disabled, - ArcadeMachineLocations.internal_name: ArcadeMachineLocations.option_disabled, - SpecialOrderLocations.internal_name: SpecialOrderLocations.option_vanilla_very_short, - QuestLocations.internal_name: "none", - Fishsanity.internal_name: Fishsanity.option_none, - Museumsanity.internal_name: Museumsanity.option_none, - Monstersanity.internal_name: Monstersanity.option_none, - Shipsanity.internal_name: Shipsanity.option_none, - Cooksanity.internal_name: Cooksanity.option_none, - Chefsanity.internal_name: Chefsanity.option_none, - Craftsanity.internal_name: Craftsanity.option_none, - Friendsanity.internal_name: Friendsanity.option_none, - FriendsanityHeartSize.internal_name: 4, - Booksanity.internal_name: Booksanity.option_none, - Walnutsanity.internal_name: Walnutsanity.preset_none, - NumberOfMovementBuffs.internal_name: 10, - EnabledFillerBuffs.internal_name: EnabledFillerBuffs.preset_all, - ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_true, - TrapItems.internal_name: TrapItems.option_easy, - MultipleDaySleepEnabled.internal_name: MultipleDaySleepEnabled.option_true, - MultipleDaySleepCost.internal_name: "free", - ExperienceMultiplier.internal_name: "quadruple", - FriendshipMultiplier.internal_name: 800, - DebrisMultiplier.internal_name: DebrisMultiplier.option_none, - QuickStart.internal_name: QuickStart.option_true, - Gifting.internal_name: Gifting.option_true, - "death_link": "false", -} - -minsanity_settings = { - Goal.internal_name: Goal.default, - FarmType.internal_name: "random", - StartingMoney.internal_name: StartingMoney.default, - ProfitMargin.internal_name: ProfitMargin.default, - BundleRandomization.internal_name: BundleRandomization.default, - BundlePrice.internal_name: BundlePrice.default, - EntranceRandomization.internal_name: EntranceRandomization.default, - SeasonRandomization.internal_name: SeasonRandomization.option_disabled, - Cropsanity.internal_name: Cropsanity.option_disabled, - BackpackProgression.internal_name: BackpackProgression.option_vanilla, - ToolProgression.internal_name: ToolProgression.option_vanilla, - ElevatorProgression.internal_name: ElevatorProgression.option_vanilla, - SkillProgression.internal_name: SkillProgression.option_vanilla, - BuildingProgression.internal_name: BuildingProgression.option_vanilla, - FestivalLocations.internal_name: FestivalLocations.option_disabled, - ArcadeMachineLocations.internal_name: ArcadeMachineLocations.option_disabled, - SpecialOrderLocations.internal_name: SpecialOrderLocations.option_vanilla_very_short, - QuestLocations.internal_name: "none", - Fishsanity.internal_name: Fishsanity.option_none, - Museumsanity.internal_name: Museumsanity.option_none, - Monstersanity.internal_name: Monstersanity.option_none, - Shipsanity.internal_name: Shipsanity.option_none, - Cooksanity.internal_name: Cooksanity.option_none, - Chefsanity.internal_name: Chefsanity.option_none, - Craftsanity.internal_name: Craftsanity.option_none, - Friendsanity.internal_name: Friendsanity.option_none, - FriendsanityHeartSize.internal_name: FriendsanityHeartSize.default, - Booksanity.internal_name: Booksanity.option_none, - Walnutsanity.internal_name: Walnutsanity.preset_none, - NumberOfMovementBuffs.internal_name: NumberOfMovementBuffs.default, - EnabledFillerBuffs.internal_name: EnabledFillerBuffs.default, - ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_true, - TrapItems.internal_name: TrapItems.default, - MultipleDaySleepEnabled.internal_name: MultipleDaySleepEnabled.default, - MultipleDaySleepCost.internal_name: MultipleDaySleepCost.default, - ExperienceMultiplier.internal_name: ExperienceMultiplier.default, - FriendshipMultiplier.internal_name: FriendshipMultiplier.default, - DebrisMultiplier.internal_name: DebrisMultiplier.default, - QuickStart.internal_name: QuickStart.default, - Gifting.internal_name: Gifting.default, - "death_link": DeathLink.default, -} - -allsanity_settings = { - Goal.internal_name: Goal.default, - FarmType.internal_name: "random", - StartingMoney.internal_name: StartingMoney.default, - ProfitMargin.internal_name: ProfitMargin.default, - BundleRandomization.internal_name: BundleRandomization.default, - BundlePrice.internal_name: BundlePrice.default, - EntranceRandomization.internal_name: EntranceRandomization.option_buildings, - SeasonRandomization.internal_name: SeasonRandomization.option_randomized, - Cropsanity.internal_name: Cropsanity.option_enabled, - BackpackProgression.internal_name: BackpackProgression.option_early_progressive, - ToolProgression.internal_name: ToolProgression.option_progressive, - ElevatorProgression.internal_name: ElevatorProgression.option_progressive, - SkillProgression.internal_name: SkillProgression.option_progressive_with_masteries, - BuildingProgression.internal_name: BuildingProgression.option_progressive, - FestivalLocations.internal_name: FestivalLocations.option_hard, - ArcadeMachineLocations.internal_name: ArcadeMachineLocations.option_full_shuffling, - SpecialOrderLocations.internal_name: SpecialOrderLocations.option_board_qi, - QuestLocations.internal_name: "maximum", - Fishsanity.internal_name: Fishsanity.option_all, - Museumsanity.internal_name: Museumsanity.option_all, - Monstersanity.internal_name: Monstersanity.option_progressive_goals, - Shipsanity.internal_name: Shipsanity.option_everything, - Cooksanity.internal_name: Cooksanity.option_all, - Chefsanity.internal_name: Chefsanity.option_all, - Craftsanity.internal_name: Craftsanity.option_all, - Friendsanity.internal_name: Friendsanity.option_all, - FriendsanityHeartSize.internal_name: 1, - Booksanity.internal_name: Booksanity.option_all, - Walnutsanity.internal_name: Walnutsanity.preset_all, - NumberOfMovementBuffs.internal_name: 12, - EnabledFillerBuffs.internal_name: EnabledFillerBuffs.preset_all, - ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_false, - TrapItems.internal_name: TrapItems.default, - MultipleDaySleepEnabled.internal_name: MultipleDaySleepEnabled.default, - MultipleDaySleepCost.internal_name: MultipleDaySleepCost.default, - ExperienceMultiplier.internal_name: ExperienceMultiplier.default, - FriendshipMultiplier.internal_name: FriendshipMultiplier.default, - DebrisMultiplier.internal_name: DebrisMultiplier.default, - QuickStart.internal_name: QuickStart.default, - Gifting.internal_name: Gifting.default, - "death_link": DeathLink.default, -} -# @formatter:on - - -sv_options_presets: Dict[str, Dict[str, Any]] = { - "All random": all_random_settings, - "Easy": easy_settings, - "Medium": medium_settings, - "Hard": hard_settings, - "Nightmare": nightmare_settings, - "Short": short_settings, - "Minsanity": minsanity_settings, - "Allsanity": allsanity_settings, -} diff --git a/worlds/stardew_valley/rules.py b/worlds/stardew_valley/rules.py index 96f081788041..54afc31eb892 100644 --- a/worlds/stardew_valley/rules.py +++ b/worlds/stardew_valley/rules.py @@ -25,7 +25,7 @@ from .stardew_rule import And, StardewRule, true_ from .stardew_rule.indirect_connection import look_for_indirect_connection from .stardew_rule.rule_explain import explain -from .strings.ap_names.ap_option_names import OptionName +from .strings.ap_names.ap_option_names import WalnutsanityOptionName from .strings.ap_names.community_upgrade_names import CommunityUpgrade from .strings.ap_names.mods.mod_items import SVEQuestItem, SVERunes from .strings.ap_names.transport_names import Transportation @@ -436,7 +436,7 @@ def set_walnut_rules(logic: StardewLogic, multiworld, player, world_options: Sta def set_walnut_puzzle_rules(logic: StardewLogic, multiworld, player, world_options): - if OptionName.walnutsanity_puzzles not in world_options.walnutsanity: + if WalnutsanityOptionName.puzzles not in world_options.walnutsanity: return MultiWorldRules.add_rule(multiworld.get_location("Open Golden Coconut", player), logic.has(Geode.golden_coconut)) @@ -463,14 +463,14 @@ def set_walnut_puzzle_rules(logic: StardewLogic, multiworld, player, world_optio def set_walnut_bushes_rules(logic, multiworld, player, world_options): - if OptionName.walnutsanity_bushes not in world_options.walnutsanity: + if WalnutsanityOptionName.bushes not in world_options.walnutsanity: return # I don't think any of the bushes require something special, but that might change with ER return def set_walnut_dig_spot_rules(logic, multiworld, player, world_options): - if OptionName.walnutsanity_dig_spots not in world_options.walnutsanity: + if WalnutsanityOptionName.dig_spots not in world_options.walnutsanity: return for dig_spot_walnut in locations.locations_by_tag[LocationTags.WALNUTSANITY_DIG]: @@ -483,7 +483,7 @@ def set_walnut_dig_spot_rules(logic, multiworld, player, world_options): def set_walnut_repeatable_rules(logic, multiworld, player, world_options): - if OptionName.walnutsanity_repeatables not in world_options.walnutsanity: + if WalnutsanityOptionName.repeatables not in world_options.walnutsanity: return for i in range(1, 6): MultiWorldRules.set_rule(multiworld.get_location(f"Fishing Walnut {i}", player), logic.tool.has_fishing_rod(1)) diff --git a/worlds/stardew_valley/strings/ap_names/ap_option_names.py b/worlds/stardew_valley/strings/ap_names/ap_option_names.py index a5cc10f7d7b8..7ff2cc783d11 100644 --- a/worlds/stardew_valley/strings/ap_names/ap_option_names.py +++ b/worlds/stardew_valley/strings/ap_names/ap_option_names.py @@ -1,16 +1,19 @@ -class OptionName: - walnutsanity_puzzles = "Puzzles" - walnutsanity_bushes = "Bushes" - walnutsanity_dig_spots = "Dig Spots" - walnutsanity_repeatables = "Repeatables" - buff_luck = "Luck" - buff_damage = "Damage" - buff_defense = "Defense" - buff_immunity = "Immunity" - buff_health = "Health" - buff_energy = "Energy" - buff_bite = "Bite Rate" - buff_fish_trap = "Fish Trap" - buff_fishing_bar = "Fishing Bar Size" - buff_quality = "Quality" - buff_glow = "Glow" +class WalnutsanityOptionName: + puzzles = "Puzzles" + bushes = "Bushes" + dig_spots = "Dig Spots" + repeatables = "Repeatables" + + +class BuffOptionName: + luck = "Luck" + damage = "Damage" + defense = "Defense" + immunity = "Immunity" + health = "Health" + energy = "Energy" + bite = "Bite Rate" + fish_trap = "Fish Trap" + fishing_bar = "Fishing Bar Size" + quality = "Quality" + glow = "Glow" diff --git a/worlds/stardew_valley/strings/ap_names/mods/__init__.py b/worlds/stardew_valley/strings/ap_names/mods/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/worlds/stardew_valley/test/TestBooksanity.py b/worlds/stardew_valley/test/TestBooksanity.py index 3ca52f5728c1..942f35d961a9 100644 --- a/worlds/stardew_valley/test/TestBooksanity.py +++ b/worlds/stardew_valley/test/TestBooksanity.py @@ -1,6 +1,5 @@ from . import SVTestBase from ..options import ExcludeGingerIsland, Booksanity, Shipsanity -from ..strings.ap_names.ap_option_names import OptionName from ..strings.book_names import Book, LostBook power_books = [Book.animal_catalogue, Book.book_of_mysteries, diff --git a/worlds/stardew_valley/test/TestOptions.py b/worlds/stardew_valley/test/TestOptions.py index 9db7f06ff5a5..2cd83f013ae5 100644 --- a/worlds/stardew_valley/test/TestOptions.py +++ b/worlds/stardew_valley/test/TestOptions.py @@ -1,6 +1,6 @@ import itertools -from Options import NamedRange, Accessibility +from Options import NamedRange from . import SVTestCase, allsanity_no_mods_6_x_x, allsanity_mods_6_x_x, solo_multiworld from .assertion import WorldAssertMixin from .long.option_names import all_option_choices @@ -54,23 +54,6 @@ def test_given_goal_when_generate_then_victory_is_in_correct_location(self): victory = multi_world.find_item("Victory", 1) self.assertEqual(victory.name, location) - def test_given_perfection_goal_when_generate_then_accessibility_is_forced_to_full(self): - """There is a bug with the current victory condition of the perfection goal that can create unwinnable seeds if the accessibility is set to minimal and - the world gets flooded with progression items through plando. This will increase the amount of collected progression items pass the total amount - calculated for the world when creating the item pool. This will cause the victory condition to be met before all locations are collected, so some could - be left inaccessible, which in practice will make the seed unwinnable. - """ - for accessibility in Accessibility.options.keys(): - world_options = {Goal.internal_name: Goal.option_perfection, "accessibility": accessibility} - with self.solo_world_sub_test(f"Accessibility: {accessibility}", world_options) as (_, world): - self.assertEqual(world.options.accessibility, Accessibility.option_full) - - def test_given_allsanity_goal_when_generate_then_accessibility_is_forced_to_full(self): - for accessibility in Accessibility.options.keys(): - world_options = {Goal.internal_name: Goal.option_allsanity, "accessibility": accessibility} - with self.solo_world_sub_test(f"Accessibility: {accessibility}", world_options) as (_, world): - self.assertEqual(world.options.accessibility, Accessibility.option_full) - class TestSeasonRandomization(SVTestCase): def test_given_disabled_when_generate_then_all_seasons_are_precollected(self): @@ -144,7 +127,7 @@ def test_given_progressive_when_generate_then_tool_upgrades_are_locations(self): class TestGenerateAllOptionsWithExcludeGingerIsland(WorldAssertMixin, SVTestCase): - def test_given_choice_when_generate_exclude_ginger_island(self): + def test_given_choice_when_generate_exclude_ginger_island_then_ginger_island_is_properly_excluded(self): for option, option_choice in all_option_choices: if option is ExcludeGingerIsland: continue @@ -163,19 +146,6 @@ def test_given_choice_when_generate_exclude_ginger_island(self): self.assert_basic_checks(multiworld) self.assert_no_ginger_island_content(multiworld) - def test_given_island_related_goal_then_override_exclude_ginger_island(self): - island_goals = ["greatest_walnut_hunter", "perfection"] - for goal, exclude_island in itertools.product(island_goals, ExcludeGingerIsland.options): - world_options = { - Goal: goal, - ExcludeGingerIsland: exclude_island - } - - with self.solo_world_sub_test(f"Goal: {goal}, {ExcludeGingerIsland.internal_name}: {exclude_island}", world_options) \ - as (multiworld, stardew_world): - self.assertEqual(stardew_world.options.exclude_ginger_island, ExcludeGingerIsland.option_false) - self.assert_basic_checks(multiworld) - class TestTraps(SVTestCase): def test_given_no_traps_when_generate_then_no_trap_in_pool(self): diff --git a/worlds/stardew_valley/test/TestOptionsPairs.py b/worlds/stardew_valley/test/TestOptionsPairs.py index d953696e887d..d489ab1ff282 100644 --- a/worlds/stardew_valley/test/TestOptionsPairs.py +++ b/worlds/stardew_valley/test/TestOptionsPairs.py @@ -1,13 +1,12 @@ from . import SVTestBase from .assertion import WorldAssertMixin from .. import options -from ..options import Goal, QuestLocations class TestCrypticNoteNoQuests(WorldAssertMixin, SVTestBase): options = { - Goal.internal_name: Goal.option_cryptic_note, - QuestLocations.internal_name: "none" + options.Goal.internal_name: options.Goal.option_cryptic_note, + options.QuestLocations.internal_name: "none" } def test_given_option_pair_then_basic_checks(self): @@ -16,8 +15,8 @@ def test_given_option_pair_then_basic_checks(self): class TestCompleteCollectionNoQuests(WorldAssertMixin, SVTestBase): options = { - Goal.internal_name: Goal.option_complete_collection, - QuestLocations.internal_name: "none" + options.Goal.internal_name: options.Goal.option_complete_collection, + options.QuestLocations.internal_name: "none" } def test_given_option_pair_then_basic_checks(self): @@ -26,8 +25,8 @@ def test_given_option_pair_then_basic_checks(self): class TestProtectorOfTheValleyNoQuests(WorldAssertMixin, SVTestBase): options = { - Goal.internal_name: Goal.option_protector_of_the_valley, - QuestLocations.internal_name: "none" + options.Goal.internal_name: options.Goal.option_protector_of_the_valley, + options.QuestLocations.internal_name: "none" } def test_given_option_pair_then_basic_checks(self): @@ -36,8 +35,8 @@ def test_given_option_pair_then_basic_checks(self): class TestCraftMasterNoQuests(WorldAssertMixin, SVTestBase): options = { - Goal.internal_name: Goal.option_craft_master, - QuestLocations.internal_name: "none" + options.Goal.internal_name: options.Goal.option_craft_master, + options.QuestLocations.internal_name: "none" } def test_given_option_pair_then_basic_checks(self): @@ -46,7 +45,7 @@ def test_given_option_pair_then_basic_checks(self): class TestCraftMasterNoSpecialOrder(WorldAssertMixin, SVTestBase): options = { - options.Goal.internal_name: Goal.option_craft_master, + options.Goal.internal_name: options.Goal.option_craft_master, options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.alias_disabled, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, options.Craftsanity.internal_name: options.Craftsanity.option_none diff --git a/worlds/stardew_valley/test/TestRegions.py b/worlds/stardew_valley/test/TestRegions.py index c2e962d88a7e..bd1b67297473 100644 --- a/worlds/stardew_valley/test/TestRegions.py +++ b/worlds/stardew_valley/test/TestRegions.py @@ -3,7 +3,8 @@ from typing import Set from BaseClasses import get_seed -from . import SVTestCase, complete_options_with_default +from . import SVTestCase +from .options.utils import fill_dataclass_with_default from .. import create_content from ..options import EntranceRandomization, ExcludeGingerIsland, SkillProgression from ..regions import vanilla_regions, vanilla_connections, randomize_connections, RandomizationFlag, create_final_connections_and_regions @@ -59,7 +60,7 @@ def test_entrance_randomization(self): (EntranceRandomization.option_non_progression, RandomizationFlag.NON_PROGRESSION), (EntranceRandomization.option_buildings_without_house, RandomizationFlag.BUILDINGS), (EntranceRandomization.option_buildings, RandomizationFlag.BUILDINGS)]: - sv_options = complete_options_with_default({ + sv_options = fill_dataclass_with_default({ EntranceRandomization.internal_name: option, ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_false, SkillProgression.internal_name: SkillProgression.option_progressive_with_masteries, @@ -87,7 +88,7 @@ def test_entrance_randomization_without_island(self): (EntranceRandomization.option_buildings_without_house, RandomizationFlag.BUILDINGS), (EntranceRandomization.option_buildings, RandomizationFlag.BUILDINGS)]: - sv_options = complete_options_with_default({ + sv_options = fill_dataclass_with_default({ EntranceRandomization.internal_name: option, ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_true, SkillProgression.internal_name: SkillProgression.option_progressive_with_masteries, @@ -116,7 +117,7 @@ def test_entrance_randomization_without_island(self): f"Connections are duplicated in randomization.") def test_cannot_put_island_access_on_island(self): - sv_options = complete_options_with_default({ + sv_options = fill_dataclass_with_default({ EntranceRandomization.internal_name: EntranceRandomization.option_buildings, ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_false, SkillProgression.internal_name: SkillProgression.option_progressive_with_masteries, diff --git a/worlds/stardew_valley/test/TestWalnutsanity.py b/worlds/stardew_valley/test/TestWalnutsanity.py index e1ab348def41..c1e8c2c8f095 100644 --- a/worlds/stardew_valley/test/TestWalnutsanity.py +++ b/worlds/stardew_valley/test/TestWalnutsanity.py @@ -1,6 +1,6 @@ from . import SVTestBase from ..options import ExcludeGingerIsland, Walnutsanity -from ..strings.ap_names.ap_option_names import OptionName +from ..strings.ap_names.ap_option_names import WalnutsanityOptionName class TestWalnutsanityNone(SVTestBase): @@ -49,7 +49,7 @@ def test_logic_received_walnuts(self): class TestWalnutsanityPuzzles(SVTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, - Walnutsanity: frozenset({OptionName.walnutsanity_puzzles}), + Walnutsanity: frozenset({WalnutsanityOptionName.puzzles}), } def test_only_puzzle_walnut_locations(self): @@ -90,7 +90,7 @@ def test_field_office_locations_require_professor_snail(self): class TestWalnutsanityBushes(SVTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, - Walnutsanity: frozenset({OptionName.walnutsanity_bushes}), + Walnutsanity: frozenset({WalnutsanityOptionName.bushes}), } def test_only_bush_walnut_locations(self): @@ -108,7 +108,7 @@ def test_only_bush_walnut_locations(self): class TestWalnutsanityPuzzlesAndBushes(SVTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, - Walnutsanity: frozenset({OptionName.walnutsanity_puzzles, OptionName.walnutsanity_bushes}), + Walnutsanity: frozenset({WalnutsanityOptionName.puzzles, WalnutsanityOptionName.bushes}), } def test_only_bush_walnut_locations(self): @@ -136,7 +136,7 @@ def test_logic_received_walnuts(self): class TestWalnutsanityDigSpots(SVTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, - Walnutsanity: frozenset({OptionName.walnutsanity_dig_spots}), + Walnutsanity: frozenset({WalnutsanityOptionName.dig_spots}), } def test_only_dig_spots_walnut_locations(self): @@ -154,7 +154,7 @@ def test_only_dig_spots_walnut_locations(self): class TestWalnutsanityRepeatables(SVTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, - Walnutsanity: frozenset({OptionName.walnutsanity_repeatables}), + Walnutsanity: frozenset({WalnutsanityOptionName.repeatables}), } def test_only_repeatable_walnut_locations(self): diff --git a/worlds/stardew_valley/test/__init__.py b/worlds/stardew_valley/test/__init__.py index 1a312e569d11..de0ed97882e3 100644 --- a/worlds/stardew_valley/test/__init__.py +++ b/worlds/stardew_valley/test/__init__.py @@ -2,18 +2,17 @@ import os import threading import unittest -from argparse import Namespace from contextlib import contextmanager from typing import Dict, ClassVar, Iterable, Tuple, Optional, List, Union, Any -from BaseClasses import MultiWorld, CollectionState, PlandoOptions, get_seed, Location, Item -from Options import VerifyKeys +from BaseClasses import MultiWorld, CollectionState, get_seed, Location, Item from test.bases import WorldTestBase from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_multiworld from worlds.AutoWorld import call_all from .assertion import RuleAssertMixin +from .options.utils import fill_namespace_with_default, parse_class_option_keys, fill_dataclass_with_default from .. import StardewValleyWorld, options, StardewItem -from ..options import StardewValleyOptions, StardewValleyOption +from ..options import StardewValleyOption logger = logging.getLogger(__name__) @@ -360,15 +359,7 @@ def setup_solo_multiworld(test_options: Optional[Dict[Union[str, StardewValleyOp multiworld = setup_base_solo_multiworld(StardewValleyWorld, (), seed=seed) # print(f"Seed: {multiworld.seed}") # Uncomment to print the seed for every test - args = Namespace() - for name, option in StardewValleyWorld.options_dataclass.type_hints.items(): - value = option.from_any(test_options.get(name, option.default)) - - if issubclass(option, VerifyKeys): - # Values should already be verified, but just in case... - value.verify(StardewValleyWorld, "Tester", PlandoOptions.bosses) - - setattr(args, name, {1: value}) + args = fill_namespace_with_default(test_options) multiworld.set_options(args) if "start_inventory" in test_options: @@ -388,24 +379,6 @@ def setup_solo_multiworld(test_options: Optional[Dict[Union[str, StardewValleyOp return multiworld -def parse_class_option_keys(test_options: Optional[Dict]) -> dict: - """ Now the option class is allowed as key. """ - if test_options is None: - return {} - parsed_options = {} - - for option, value in test_options.items(): - if hasattr(option, "internal_name"): - assert option.internal_name not in test_options, "Defined two times by class and internal_name" - parsed_options[option.internal_name] = value - else: - assert option in StardewValleyOptions.type_hints, \ - f"All keys of world_options must be a possible Stardew Valley option, {option} is not." - parsed_options[option] = value - - return parsed_options - - def search_world_cache(cache: Dict[frozenset, MultiWorld], frozen_options: frozenset) -> Optional[MultiWorld]: try: return cache[frozen_options] @@ -421,16 +394,6 @@ def add_to_world_cache(cache: Dict[frozenset, MultiWorld], frozen_options: froze cache[frozen_options] = multi_world -def complete_options_with_default(options_to_complete=None) -> StardewValleyOptions: - if options_to_complete is None: - options_to_complete = {} - - for name, option in StardewValleyOptions.type_hints.items(): - options_to_complete[name] = option.from_any(options_to_complete.get(name, option.default)) - - return StardewValleyOptions(**options_to_complete) - - def setup_multiworld(test_options: Iterable[Dict[str, int]] = None, seed=None) -> MultiWorld: # noqa if test_options is None: test_options = [] @@ -442,22 +405,10 @@ def setup_multiworld(test_options: Iterable[Dict[str, int]] = None, seed=None) - for i in range(1, len(test_options) + 1): multiworld.game[i] = StardewValleyWorld.game multiworld.player_name.update({i: f"Tester{i}"}) - args = create_args(test_options) + args = fill_namespace_with_default(test_options) multiworld.set_options(args) for step in gen_steps: call_all(multiworld, step) return multiworld - - -def create_args(test_options): - args = Namespace() - for name, option in StardewValleyWorld.options_dataclass.type_hints.items(): - options = {} - for i in range(1, len(test_options) + 1): - player_options = test_options[i - 1] - value = option(player_options[name]) if name in player_options else option.from_any(option.default) - options.update({i: value}) - setattr(args, name, options) - return args diff --git a/worlds/stardew_valley/test/mods/TestMods.py b/worlds/stardew_valley/test/mods/TestMods.py index 56138cf582a7..89f82870e4a7 100644 --- a/worlds/stardew_valley/test/mods/TestMods.py +++ b/worlds/stardew_valley/test/mods/TestMods.py @@ -1,7 +1,8 @@ import random from BaseClasses import get_seed -from .. import SVTestBase, SVTestCase, allsanity_no_mods_6_x_x, allsanity_mods_6_x_x, complete_options_with_default, solo_multiworld +from .. import SVTestBase, SVTestCase, allsanity_no_mods_6_x_x, allsanity_mods_6_x_x, solo_multiworld, \ + fill_dataclass_with_default from ..assertion import ModAssertMixin, WorldAssertMixin from ... import items, Group, ItemClassification, create_content from ... import options @@ -122,7 +123,7 @@ def test_mod_entrance_randomization(self): (options.EntranceRandomization.option_non_progression, RandomizationFlag.NON_PROGRESSION), (options.EntranceRandomization.option_buildings_without_house, RandomizationFlag.BUILDINGS), (options.EntranceRandomization.option_buildings, RandomizationFlag.BUILDINGS)]: - sv_options = complete_options_with_default({ + sv_options = fill_dataclass_with_default({ options.EntranceRandomization.internal_name: option, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, SkillProgression.internal_name: SkillProgression.option_progressive_with_masteries, diff --git a/worlds/stardew_valley/test/options/TestForcedOptions.py b/worlds/stardew_valley/test/options/TestForcedOptions.py new file mode 100644 index 000000000000..4c8f0f42c389 --- /dev/null +++ b/worlds/stardew_valley/test/options/TestForcedOptions.py @@ -0,0 +1,84 @@ +import itertools +import unittest + +import Options as ap_options +from .utils import fill_dataclass_with_default +from ... import options +from ...options.forced_options import force_change_options_if_incompatible + + +class TestGoalsRequiringAllLocationsOverrideAccessibility(unittest.TestCase): + + def test_given_goal_requiring_all_locations_when_generate_then_accessibility_is_forced_to_full(self): + """There is a bug with the current victory condition of the perfection goal that can create unwinnable seeds if the accessibility is set to minimal and + the world gets flooded with progression items through plando. This will increase the amount of collected progression items pass the total amount + calculated for the world when creating the item pool. This will cause the victory condition to be met before all locations are collected, so some could + be left inaccessible, which in practice will make the seed unwinnable. + """ + + for goal in [options.Goal.option_perfection, options.Goal.option_allsanity]: + for accessibility in ap_options.Accessibility.options.keys(): + with self.subTest(f"Goal: {options.Goal.get_option_name(goal)} Accessibility: {accessibility}"): + world_options = fill_dataclass_with_default({ + options.Goal: goal, + "accessibility": accessibility + }) + + force_change_options_if_incompatible(world_options, 1, "Tester") + + self.assertEqual(world_options.accessibility.value, ap_options.Accessibility.option_full) + + +class TestGingerIslandRelatedGoalsOverrideGingerIslandExclusion(unittest.TestCase): + + def test_given_island_related_goal_when_generate_then_override_exclude_ginger_island(self): + for goal in [options.Goal.option_greatest_walnut_hunter, options.Goal.option_perfection]: + for exclude_island in options.ExcludeGingerIsland.options: + with self.subTest(f"Goal: {options.Goal.get_option_name(goal)} Exclude Ginger Island: {exclude_island}"): + world_options = fill_dataclass_with_default({ + options.Goal: goal, + options.ExcludeGingerIsland: exclude_island + }) + + force_change_options_if_incompatible(world_options, 1, "Tester") + + self.assertEqual(world_options.exclude_ginger_island.value, options.ExcludeGingerIsland.option_false) + + +class TestGingerIslandExclusionOverridesWalnutsanity(unittest.TestCase): + + def test_given_ginger_island_excluded_when_generate_then_walnutsanity_is_forced_disabled(self): + walnutsanity_options = options.Walnutsanity.valid_keys + for walnutsanity in ( + walnutsanity + for r in range(len(walnutsanity_options) + 1) + for walnutsanity in itertools.combinations(walnutsanity_options, r) + ): + with self.subTest(f"Walnutsanity: {walnutsanity}"): + world_options = fill_dataclass_with_default({ + options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_true, + options.Walnutsanity: walnutsanity + }) + + force_change_options_if_incompatible(world_options, 1, "Tester") + + self.assertEqual(world_options.walnutsanity.value, options.Walnutsanity.preset_none) + + def test_given_ginger_island_related_goal_and_ginger_island_excluded_when_generate_then_walnutsanity_is_not_changed(self): + for goal in [options.Goal.option_greatest_walnut_hunter, options.Goal.option_perfection]: + walnutsanity_options = options.Walnutsanity.valid_keys + for original_walnutsanity_choice in ( + set(walnutsanity) + for r in range(len(walnutsanity_options) + 1) + for walnutsanity in itertools.combinations(walnutsanity_options, r) + ): + with self.subTest(f"Goal: {options.Goal.get_option_name(goal)} Walnutsanity: {original_walnutsanity_choice}"): + world_options = fill_dataclass_with_default({ + options.Goal: goal, + options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_true, + options.Walnutsanity: original_walnutsanity_choice + }) + + force_change_options_if_incompatible(world_options, 1, "Tester") + + self.assertEqual(world_options.walnutsanity.value, original_walnutsanity_choice) diff --git a/worlds/stardew_valley/test/TestPresets.py b/worlds/stardew_valley/test/options/TestPresets.py similarity index 86% rename from worlds/stardew_valley/test/TestPresets.py rename to worlds/stardew_valley/test/options/TestPresets.py index 2bb1c7fbaeaf..9384acd77060 100644 --- a/worlds/stardew_valley/test/TestPresets.py +++ b/worlds/stardew_valley/test/options/TestPresets.py @@ -1,9 +1,7 @@ -import builtins -import inspect - from Options import PerGameCommonOptions, OptionSet -from . import SVTestCase -from .. import sv_options_presets, StardewValleyOptions +from .. import SVTestCase +from ...options import StardewValleyOptions +from ...options.presets import sv_options_presets class TestPresets(SVTestCase): @@ -18,4 +16,4 @@ def test_all_presets_explicitly_set_all_options(self): with self.subTest(f"{preset_name}"): for option_name in mandatory_option_names: with self.subTest(f"{preset_name} -> {option_name}"): - self.assertIn(option_name, sv_options_presets[preset_name]) \ No newline at end of file + self.assertIn(option_name, sv_options_presets[preset_name]) diff --git a/worlds/stardew_valley/test/options/__init__.py b/worlds/stardew_valley/test/options/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/worlds/stardew_valley/test/options/utils.py b/worlds/stardew_valley/test/options/utils.py new file mode 100644 index 000000000000..9f02105da84f --- /dev/null +++ b/worlds/stardew_valley/test/options/utils.py @@ -0,0 +1,68 @@ +from argparse import Namespace +from typing import Any, Iterable + +from BaseClasses import PlandoOptions +from Options import VerifyKeys +from ... import StardewValleyWorld +from ...options import StardewValleyOptions, StardewValleyOption + + +def parse_class_option_keys(test_options: dict[str | StardewValleyOption, Any] | None) -> dict: + """ Now the option class is allowed as key. """ + if test_options is None: + return {} + parsed_options = {} + + for option, value in test_options.items(): + if hasattr(option, "internal_name"): + assert option.internal_name not in test_options, "Defined two times by class and internal_name" + parsed_options[option.internal_name] = value + else: + assert option in StardewValleyOptions.type_hints, \ + f"All keys of world_options must be a possible Stardew Valley option, {option} is not." + parsed_options[option] = value + + return parsed_options + + +def fill_dataclass_with_default(test_options: dict[str | StardewValleyOption, Any] | None) -> StardewValleyOptions: + test_options = parse_class_option_keys(test_options) + + filled_options = {} + for option_name, option_class in StardewValleyOptions.type_hints.items(): + + value = option_class.from_any(test_options.get(option_name, option_class.default)) + + if issubclass(option_class, VerifyKeys): + # Values should already be verified, but just in case... + value.verify(StardewValleyWorld, "Tester", PlandoOptions.bosses) + + filled_options[option_name] = value + + return StardewValleyOptions(**filled_options) + + +def fill_namespace_with_default(test_options: dict[str, Any] | Iterable[dict[str, Any]]) -> Namespace: + if isinstance(test_options, dict): + test_options = [test_options] + + args = Namespace() + for option_name, option_class in StardewValleyOptions.type_hints.items(): + all_players_option = {} + + for player_id, player_options in enumerate(test_options): + # Player id starts at 1 + player_id += 1 + player_name = f"Tester{player_id}" + + value = option_class.from_any(player_options.get(option_name, option_class.default)) + + if issubclass(option_class, VerifyKeys): + # Values should already be verified, but just in case... + value.verify(StardewValleyWorld, player_name, PlandoOptions.bosses) + + all_players_option[player_id] = value + + setattr(args, option_name, all_players_option) + + return args diff --git a/worlds/stardew_valley/test/stability/TestUniversalTracker.py b/worlds/stardew_valley/test/stability/TestUniversalTracker.py index 3e334098341d..4655b37adf07 100644 --- a/worlds/stardew_valley/test/stability/TestUniversalTracker.py +++ b/worlds/stardew_valley/test/stability/TestUniversalTracker.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import Mock -from .. import SVTestBase, create_args, allsanity_mods_6_x_x +from .. import SVTestBase, allsanity_mods_6_x_x, fill_namespace_with_default from ... import STARDEW_VALLEY, FarmType, BundleRandomization, EntranceRandomization @@ -29,7 +29,7 @@ def test_all_locations_and_items_are_the_same_between_two_generations(self): fake_context = Mock() fake_context.re_gen_passthrough = {STARDEW_VALLEY: ut_data} - args = create_args({0: self.options}) + args = fill_namespace_with_default({0: self.options}) args.outputpath = None args.outputname = None args.multi = 1 From aa22b62b41226b62734988da0b5dcd6f5bff7a33 Mon Sep 17 00:00:00 2001 From: Jouramie <16137441+Jouramie@users.noreply.github.com> Date: Mon, 9 Dec 2024 15:17:25 -0500 Subject: [PATCH 006/144] Stardew Valley: Force deactivation of Mr. Qi's special orders when ginger island is deactivated (#4348) --- .../stardew_valley/options/forced_options.py | 12 +++++++ .../test/options/TestForcedOptions.py | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/worlds/stardew_valley/options/forced_options.py b/worlds/stardew_valley/options/forced_options.py index 84cdc936b3f1..7429f3cbfc65 100644 --- a/worlds/stardew_valley/options/forced_options.py +++ b/worlds/stardew_valley/options/forced_options.py @@ -9,6 +9,7 @@ def force_change_options_if_incompatible(world_options: options.StardewValleyOptions, player: int, player_name: str) -> None: force_ginger_island_inclusion_when_goal_is_ginger_island_related(world_options, player, player_name) force_walnutsanity_deactivation_when_ginger_island_is_excluded(world_options, player, player_name) + force_qi_special_orders_deactivation_when_ginger_island_is_excluded(world_options, player, player_name) force_accessibility_to_full_when_goal_requires_all_locations(player, player_name, world_options) @@ -35,6 +36,17 @@ def force_walnutsanity_deactivation_when_ginger_island_is_excluded(world_options f"Ginger Island was excluded from {player} ({player_name})'s world, so walnutsanity was force disabled") +def force_qi_special_orders_deactivation_when_ginger_island_is_excluded(world_options: options.StardewValleyOptions, player: int, player_name: str): + ginger_island_is_excluded = world_options.exclude_ginger_island == options.ExcludeGingerIsland.option_true + qi_board_is_active = world_options.special_order_locations.value & options.SpecialOrderLocations.value_qi + + if ginger_island_is_excluded and qi_board_is_active: + original_option_name = world_options.special_order_locations.current_option_name + world_options.special_order_locations.value -= options.SpecialOrderLocations.value_qi + logger.warning(f"Mr. Qi's Special Orders requires Ginger Island. " + f"Ginger Island was excluded from {player} ({player_name})'s world, so Special Order Locations was changed from {original_option_name} to {world_options.special_order_locations.current_option_name}") + + def force_accessibility_to_full_when_goal_requires_all_locations(player, player_name, world_options): goal_is_allsanity = world_options.goal == options.Goal.option_allsanity goal_is_perfection = world_options.goal == options.Goal.option_perfection diff --git a/worlds/stardew_valley/test/options/TestForcedOptions.py b/worlds/stardew_valley/test/options/TestForcedOptions.py index 4c8f0f42c389..c32def6c6ca8 100644 --- a/worlds/stardew_valley/test/options/TestForcedOptions.py +++ b/worlds/stardew_valley/test/options/TestForcedOptions.py @@ -82,3 +82,34 @@ def test_given_ginger_island_related_goal_and_ginger_island_excluded_when_genera force_change_options_if_incompatible(world_options, 1, "Tester") self.assertEqual(world_options.walnutsanity.value, original_walnutsanity_choice) + + +class TestGingerIslandExclusionOverridesQisSpecialOrders(unittest.TestCase): + + def test_given_ginger_island_excluded_when_generate_then_qis_special_orders_are_forced_disabled(self): + special_order_options = options.SpecialOrderLocations.options + for special_order in special_order_options.keys(): + with self.subTest(f"Special order: {special_order}"): + world_options = fill_dataclass_with_default({ + options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_true, + options.SpecialOrderLocations: special_order + }) + + force_change_options_if_incompatible(world_options, 1, "Tester") + + self.assertEqual(world_options.special_order_locations.value & options.SpecialOrderLocations.value_qi, 0) + + def test_given_ginger_island_related_goal_and_ginger_island_excluded_when_generate_then_special_orders_is_not_changed(self): + for goal in [options.Goal.option_greatest_walnut_hunter, options.Goal.option_perfection]: + special_order_options = options.SpecialOrderLocations.options + for special_order, original_special_order_value in special_order_options.items(): + with self.subTest(f"Special order: {special_order}"): + world_options = fill_dataclass_with_default({ + options.Goal: goal, + options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_true, + options.SpecialOrderLocations: special_order + }) + + force_change_options_if_incompatible(world_options, 1, "Tester") + + self.assertEqual(world_options.special_order_locations.value, original_special_order_value) From 0b3d34ab24ffab023800e6230ca95e840cdcb683 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 10 Dec 2024 02:25:09 +0100 Subject: [PATCH 007/144] CI: update scan-build to v19 (#4338) --- .github/workflows/scan-build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml index 5234d862b4d3..ac842070625f 100644 --- a/.github/workflows/scan-build.yml +++ b/.github/workflows/scan-build.yml @@ -40,10 +40,10 @@ jobs: run: | wget https://apt.llvm.org/llvm.sh chmod +x ./llvm.sh - sudo ./llvm.sh 17 + sudo ./llvm.sh 19 - name: Install scan-build command run: | - sudo apt install clang-tools-17 + sudo apt install clang-tools-19 - name: Get a recent python uses: actions/setup-python@v5 with: @@ -56,7 +56,7 @@ jobs: - name: scan-build run: | source venv/bin/activate - scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y + scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y - name: Store report if: failure() uses: actions/upload-artifact@v4 From 4a5ba756b6d0d26b40a8c3ca5bb3ea3e1ed931b3 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 10 Dec 2024 02:44:41 +0100 Subject: [PATCH 008/144] WebHost: Set Generator memory limit to 4GiB (#4319) * WebHost: Set Generator memory limit to 4GiB * WebHost: make generator memory limit configurable, better naming * Update WebHostLib/__init__.py Co-authored-by: Fabian Dill * Update docs/webhost configuration sample.yaml --------- Co-authored-by: Fabian Dill --- WebHostLib/__init__.py | 2 ++ WebHostLib/autolauncher.py | 21 ++++++++++++++++++--- docs/webhost configuration sample.yaml | 10 ++++++++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 9b2b6736f13c..9c713419c986 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -39,6 +39,8 @@ app.config["JOB_THRESHOLD"] = 1 # after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable. app.config["JOB_TIME"] = 600 +# memory limit for generator processes in bytes +app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296 app.config['SESSION_PERMANENT'] = True # waitress uses one thread for I/O, these are for processing of views that then get sent diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 08a1309ebc73..8ba093e014c5 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -6,6 +6,7 @@ import typing from datetime import timedelta, datetime from threading import Event, Thread +from typing import Any from uuid import UUID from pony.orm import db_session, select, commit @@ -53,7 +54,21 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): generation.state = STATE_STARTED -def init_db(pony_config: dict): +def init_generator(config: dict[str, Any]) -> None: + try: + import resource + except ModuleNotFoundError: + pass # unix only module + else: + # set soft limit for memory to from config (default 4GiB) + soft_limit = config["GENERATOR_MEMORY_LIMIT"] + old_limit, hard_limit = resource.getrlimit(resource.RLIMIT_AS) + if soft_limit != old_limit: + resource.setrlimit(resource.RLIMIT_AS, (soft_limit, hard_limit)) + logging.debug(f"Changed AS mem limit {old_limit} -> {soft_limit}") + del resource, soft_limit, hard_limit + + pony_config = config["PONY"] db.bind(**pony_config) db.generate_mapping() @@ -105,8 +120,8 @@ def keep_running(): try: with Locker("autogen"): - with multiprocessing.Pool(config["GENERATORS"], initializer=init_db, - initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool: + with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator, + initargs=(config,), maxtasksperchild=10) as generator_pool: with db_session: to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) diff --git a/docs/webhost configuration sample.yaml b/docs/webhost configuration sample.yaml index afb87b399643..93094f1ce73f 100644 --- a/docs/webhost configuration sample.yaml +++ b/docs/webhost configuration sample.yaml @@ -27,8 +27,14 @@ # If you wish to deploy, uncomment the following line and set it to something not easily guessable. # SECRET_KEY: "Your secret key here" -# TODO -#JOB_THRESHOLD: 2 +# Slot limit to post a generation to Generator process pool instead of rolling directly in WebHost process +#JOB_THRESHOLD: 1 + +# After what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable. +#JOB_TIME: 600 + +# Memory limit for Generator processes in bytes, -1 for unlimited. Currently only works on Linux. +#GENERATOR_MEMORY_LIMIT: 4294967296 # waitress uses one thread for I/O, these are for processing of view that get sent #WAITRESS_THREADS: 10 From f79657b41a8aa7904193331b7ae181e0ff9ab967 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Tue, 10 Dec 2024 19:53:42 +0100 Subject: [PATCH 009/144] WebHost: disable abbreviations for argparse (#4352) --- WebHost.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebHost.py b/WebHost.py index 3790a5f6f4d2..768eeb512289 100644 --- a/WebHost.py +++ b/WebHost.py @@ -34,7 +34,7 @@ def get_app() -> "Flask": app.config.from_file(configpath, yaml.safe_load) logging.info(f"Updated config from {configpath}") # inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it. - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(allow_abbrev=False) parser.add_argument('--config_override', default=None, help="Path to yaml config file that overrules config.yaml.") args = parser.parse_known_args()[0] From 3fb0b57d19b9c223107308c84605e05e6b16e1cf Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 10 Dec 2024 20:09:36 +0100 Subject: [PATCH 010/144] Core: fix exceptions coming from LocationStore (#4358) * Speedups: add instructions for ASAN * Speedups: move typevars out of classes * Speedups, NetUtils: raise correct exceptions * Speedups: double-check malloc * Tests: more LocationStore tests --- NetUtils.py | 2 ++ _speedups.pyx | 43 ++++++++++++++++++---------- _speedups.pyxbld | 18 ++++++++---- test/netutils/test_location_store.py | 41 ++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/NetUtils.py b/NetUtils.py index ec6ff3eb1d81..196a030f4969 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -410,6 +410,8 @@ def get_checked(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int] checked = state[team, slot] if not checked: # This optimizes the case where everyone connects to a fresh game at the same time. + if slot not in self: + raise KeyError(slot) return [] return [location_id for location_id in self[slot] if diff --git a/_speedups.pyx b/_speedups.pyx index dc039e336500..2ad1a2953a2b 100644 --- a/_speedups.pyx +++ b/_speedups.pyx @@ -69,6 +69,14 @@ cdef struct IndexEntry: size_t count +if TYPE_CHECKING: + State = Dict[Tuple[int, int], Set[int]] +else: + State = Union[Tuple[int, int], Set[int], defaultdict] + +T = TypeVar('T') + + @cython.auto_pickle(False) cdef class LocationStore: """Compact store for locations and their items in a MultiServer""" @@ -137,10 +145,16 @@ cdef class LocationStore: warnings.warn("Game has no locations") # allocate the arrays and invalidate index (0xff...) - self.entries = self._mem.alloc(count, sizeof(LocationEntry)) + if count: + # leaving entries as NULL if there are none, makes potential memory errors more visible + self.entries = self._mem.alloc(count, sizeof(LocationEntry)) self.sender_index = self._mem.alloc(max_sender + 1, sizeof(IndexEntry)) self._raw_proxies = self._mem.alloc(max_sender + 1, sizeof(PyObject*)) + assert (not self.entries) == (not count) + assert self.sender_index + assert self._raw_proxies + # build entries and index cdef size_t i = 0 for sender, locations in sorted(locations_dict.items()): @@ -190,8 +204,6 @@ cdef class LocationStore: raise KeyError(key) return self._raw_proxies[key] - T = TypeVar('T') - def get(self, key: int, default: T) -> Union[PlayerLocationProxy, T]: # calling into self.__getitem__ here is slow, but this is not used in MultiServer try: @@ -246,12 +258,11 @@ cdef class LocationStore: all_locations[sender].add(entry.location) return all_locations - if TYPE_CHECKING: - State = Dict[Tuple[int, int], Set[int]] - else: - State = Union[Tuple[int, int], Set[int], defaultdict] - def get_checked(self, state: State, team: int, slot: int) -> List[int]: + cdef ap_player_t sender = slot + if sender < 0 or sender >= self.sender_index_size: + raise KeyError(slot) + # This used to validate checks actually exist. A remnant from the past. # If the order of locations becomes relevant at some point, we could not do sorted(set), so leaving it. cdef set checked = state[team, slot] @@ -263,7 +274,6 @@ cdef class LocationStore: # Unless the set is close to empty, it's cheaper to use the python set directly, so we do that. cdef LocationEntry* entry - cdef ap_player_t sender = slot cdef size_t start = self.sender_index[sender].start cdef size_t count = self.sender_index[sender].count return [entry.location for @@ -273,9 +283,11 @@ cdef class LocationStore: def get_missing(self, state: State, team: int, slot: int) -> List[int]: cdef LocationEntry* entry cdef ap_player_t sender = slot + if sender < 0 or sender >= self.sender_index_size: + raise KeyError(slot) + cdef set checked = state[team, slot] cdef size_t start = self.sender_index[sender].start cdef size_t count = self.sender_index[sender].count - cdef set checked = state[team, slot] if not len(checked): # Skip `in` if none have been checked. # This optimizes the case where everyone connects to a fresh game at the same time. @@ -290,9 +302,11 @@ cdef class LocationStore: def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]: cdef LocationEntry* entry cdef ap_player_t sender = slot + if sender < 0 or sender >= self.sender_index_size: + raise KeyError(slot) + cdef set checked = state[team, slot] cdef size_t start = self.sender_index[sender].start cdef size_t count = self.sender_index[sender].count - cdef set checked = state[team, slot] return sorted([(entry.receiver, entry.item) for entry in self.entries[start:start+count] if entry.location not in checked]) @@ -328,7 +342,8 @@ cdef class PlayerLocationProxy: cdef LocationEntry* entry = NULL # binary search cdef size_t l = self._store.sender_index[self._player].start - cdef size_t r = l + self._store.sender_index[self._player].count + cdef size_t e = l + self._store.sender_index[self._player].count + cdef size_t r = e cdef size_t m while l < r: m = (l + r) // 2 @@ -337,7 +352,7 @@ cdef class PlayerLocationProxy: l = m + 1 else: r = m - if entry: # count != 0 + if l < e: entry = self._store.entries + l if entry.location == loc: return entry @@ -349,8 +364,6 @@ cdef class PlayerLocationProxy: return entry.item, entry.receiver, entry.flags raise KeyError(f"No location {key} for player {self._player}") - T = TypeVar('T') - def get(self, key: int, default: T) -> Union[Tuple[int, int, int], T]: cdef LocationEntry* entry = self._get(key) if entry: diff --git a/_speedups.pyxbld b/_speedups.pyxbld index 974eaed03b6a..98f9734614cc 100644 --- a/_speedups.pyxbld +++ b/_speedups.pyxbld @@ -3,8 +3,16 @@ import os def make_ext(modname, pyxfilename): from distutils.extension import Extension - return Extension(name=modname, - sources=[pyxfilename], - depends=["intset.h"], - include_dirs=[os.getcwd()], - language="c") + return Extension( + name=modname, + sources=[pyxfilename], + depends=["intset.h"], + include_dirs=[os.getcwd()], + language="c", + # to enable ASAN and debug build: + # extra_compile_args=["-fsanitize=address", "-UNDEBUG", "-Og", "-g"], + # extra_objects=["-fsanitize=address"], + # NOTE: we can not put -lasan at the front of link args, so needs to be run with + # LD_PRELOAD=/usr/lib/libasan.so ASAN_OPTIONS=detect_leaks=0 path/to/exe + # NOTE: this can't find everything unless libpython and cymem are also built with ASAN + ) diff --git a/test/netutils/test_location_store.py b/test/netutils/test_location_store.py index 1b984015844d..264f35b3cc65 100644 --- a/test/netutils/test_location_store.py +++ b/test/netutils/test_location_store.py @@ -115,6 +115,7 @@ def test_find_item(self) -> None: def test_get_for_player(self) -> None: self.assertEqual(self.store.get_for_player(3), {4: {9}}) self.assertEqual(self.store.get_for_player(1), {1: {13}, 2: {22, 23}}) + self.assertEqual(self.store.get_for_player(9999), {}) def test_get_checked(self) -> None: self.assertEqual(self.store.get_checked(full_state, 0, 1), [11, 12, 13]) @@ -122,18 +123,48 @@ def test_get_checked(self) -> None: self.assertEqual(self.store.get_checked(empty_state, 0, 1), []) self.assertEqual(self.store.get_checked(full_state, 0, 3), [9]) + def test_get_checked_exception(self) -> None: + with self.assertRaises(KeyError): + self.store.get_checked(empty_state, 0, 9999) + bad_state = {(0, 6): {1}} + with self.assertRaises(KeyError): + self.store.get_checked(bad_state, 0, 6) + bad_state = {(0, 9999): set()} + with self.assertRaises(KeyError): + self.store.get_checked(bad_state, 0, 9999) + def test_get_missing(self) -> None: self.assertEqual(self.store.get_missing(full_state, 0, 1), []) self.assertEqual(self.store.get_missing(one_state, 0, 1), [11, 13]) self.assertEqual(self.store.get_missing(empty_state, 0, 1), [11, 12, 13]) self.assertEqual(self.store.get_missing(empty_state, 0, 3), [9]) + def test_get_missing_exception(self) -> None: + with self.assertRaises(KeyError): + self.store.get_missing(empty_state, 0, 9999) + bad_state = {(0, 6): {1}} + with self.assertRaises(KeyError): + self.store.get_missing(bad_state, 0, 6) + bad_state = {(0, 9999): set()} + with self.assertRaises(KeyError): + self.store.get_missing(bad_state, 0, 9999) + def test_get_remaining(self) -> None: self.assertEqual(self.store.get_remaining(full_state, 0, 1), []) self.assertEqual(self.store.get_remaining(one_state, 0, 1), [(1, 13), (2, 21)]) self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [(1, 13), (2, 21), (2, 22)]) self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [(4, 99)]) + def test_get_remaining_exception(self) -> None: + with self.assertRaises(KeyError): + self.store.get_remaining(empty_state, 0, 9999) + bad_state = {(0, 6): {1}} + with self.assertRaises(KeyError): + self.store.get_missing(bad_state, 0, 6) + bad_state = {(0, 9999): set()} + with self.assertRaises(KeyError): + self.store.get_remaining(bad_state, 0, 9999) + def test_location_set_intersection(self) -> None: locations = {10, 11, 12} locations.intersection_update(self.store[1]) @@ -181,6 +212,16 @@ def test_no_locations(self) -> None: }) self.assertEqual(len(store), 1) self.assertEqual(len(store[1]), 0) + self.assertEqual(sorted(store.find_item(set(), 1)), []) + self.assertEqual(sorted(store.find_item({1}, 1)), []) + self.assertEqual(sorted(store.find_item({1, 2}, 1)), []) + self.assertEqual(store.get_for_player(1), {}) + self.assertEqual(store.get_checked(empty_state, 0, 1), []) + self.assertEqual(store.get_checked(full_state, 0, 1), []) + self.assertEqual(store.get_missing(empty_state, 0, 1), []) + self.assertEqual(store.get_missing(full_state, 0, 1), []) + self.assertEqual(store.get_remaining(empty_state, 0, 1), []) + self.assertEqual(store.get_remaining(full_state, 0, 1), []) def test_no_locations_for_1(self) -> None: store = self.type({ From 781100a571fe7e4850272a8899c5ef9d078f19d8 Mon Sep 17 00:00:00 2001 From: Jouramie <16137441+Jouramie@users.noreply.github.com> Date: Tue, 10 Dec 2024 14:26:33 -0500 Subject: [PATCH 011/144] CI: remove version restriction on pytest-subtests (#4356) This reverts commit e3b5451672c694c12974801f5c89cc172db3ff5a. --- .github/workflows/unittests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 9db9de9b4042..88b5d12987ad 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -53,7 +53,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest "pytest-subtests<0.14.0" pytest-xdist + pip install pytest pytest-subtests pytest-xdist python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" python Launcher.py --update_settings # make sure host.yaml exists for tests - name: Unittests From 5dd19fccd0dc89966095c93c3fdb3ad72b4bea08 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Tue, 10 Dec 2024 20:35:36 +0100 Subject: [PATCH 012/144] MultiServer/CommonClient: We forgot about Item Links again (Hint Priority) (#4314) * Vi don't forget about itemlinks challenge difficulty impossible * People other than Vi also don't forget about ItemLinks challenge difficulty impossible --- MultiServer.py | 2 +- NetUtils.py | 2 +- kvui.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MultiServer.py b/MultiServer.py index 2561b0692a3c..0601e179152c 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -1914,7 +1914,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): hint = ctx.get_hint(client.team, player, location) if not hint: return # Ignored safely - if hint.receiving_player != client.slot: + if client.slot not in ctx.slot_set(hint.receiving_player): await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission', "original_cmd": cmd}]) diff --git a/NetUtils.py b/NetUtils.py index 196a030f4969..a961850639a0 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -232,7 +232,7 @@ def _handle_text(self, node: JSONMessagePart): def _handle_player_id(self, node: JSONMessagePart): player = int(node["text"]) - node["color"] = 'magenta' if player == self.ctx.slot else 'yellow' + node["color"] = 'magenta' if self.ctx.slot_concerns_self(player) else 'yellow' node["text"] = self.ctx.player_names[player] return self._handle_color(node) diff --git a/kvui.py b/kvui.py index d98fc7ed9ab8..b2ab004e274a 100644 --- a/kvui.py +++ b/kvui.py @@ -371,7 +371,7 @@ def on_touch_down(self, touch): if self.hint["status"] == HintStatus.HINT_FOUND: return ctx = App.get_running_app().ctx - if ctx.slot == self.hint["receiving_player"]: # If this player owns this hint + if ctx.slot_concerns_self(self.hint["receiving_player"]): # If this player owns this hint # open a dropdown self.dropdown.open(self.ids["status"]) elif self.selected: @@ -800,7 +800,7 @@ def refresh_hints(self, hints): hint_status_node = self.parser.handle_node({"type": "color", "color": status_colors.get(hint["status"], "red"), "text": status_names.get(hint["status"], "Unknown")}) - if hint["status"] != HintStatus.HINT_FOUND and hint["receiving_player"] == ctx.slot: + if hint["status"] != HintStatus.HINT_FOUND and ctx.slot_concerns_self(hint["receiving_player"]): hint_status_node = f"[u]{hint_status_node}[/u]" data.append({ "receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})}, From 925fb967d3274e64a8f88b73d81b97ce51c6d0d2 Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Tue, 10 Dec 2024 14:36:38 -0500 Subject: [PATCH 013/144] Lingo: Fix number hunt issues on panels mode (#4342) --- worlds/lingo/data/generated.dat | Bin 149230 -> 149485 bytes worlds/lingo/test/TestDatafile.py | 7 ++++++- worlds/lingo/utils/pickle_static_data.py | 16 ++++++++++------ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/worlds/lingo/data/generated.dat b/worlds/lingo/data/generated.dat index 9abb0276c8b5ac2fb1e81eff1e2c2cc06489ddec..8b159d4ae4ac7c565e2372b1cc43d7a349b2ca7c 100644 GIT binary patch delta 5053 zcma)ZV~ zz$q=awm#WI(@LE!b3fMlM#V=7mV~xjuDu_tpWUAOJLfkby0xG64}ahJp7T5RchB#4 z@71r?`+m8>cbOCXK-uFD%Lk#q8C0}XK!DEfHL0WwzfFM<+%pLFb42aXo*(4lfkVs? zhGF9&hO95nHiMtbH5k70q~G4YTBk@_?Og7}mxsV^*y@^NfVB$f@oFTjfe2g>1v!-I zgImoIfNw{^FE9)DMMDnEa{U?&Qx$jk(Jaz&KD6(5?S_K~gUuKG@gT_k<* z{9N$E0)(udb>j9)C~#%XfQ??T*VQ%~)+w+7XWk2TcoDDM3%O#5uYgP)@1(1?0%APH zazqdDskxA$)8bjTiX58D4;GxM>~);+%Pqq~wO-dn4p;@HN^X>E{wnCBS2_o|tQ+B&C$wUw6UK;Pi4#VPVYd?=g=;vz z8Agd=akG%u@Qr5Ct|5syt_9L)T+{;TG``jXk0@*8i4f~@twHGLNwr%x!<{-ZSN{kj z`av`f+5tsA(JAhgeZ3ea;@%zNz|C+~z6v`AKn4yx2uWg?co2q(!Fo`f)eL-F{`&MF z#EM`255X|CcCd$smVudvAX{B3dF3I9Q5}+Z9)ehPtK^G^AVJ+H+5a$%Q1#*fiMjcQ zVT?Y4d6b8TJ=(GViNUz-Fa)c!WWu4tkfpvU+4l%cQrjfYIszr?Cz5v^fg<%s$#4`( zRaKl7QT(x^;vGaR* zw;YFo`X71%363)vA$a~cM5v(=T;=EEFj~!zWX@~_lUgPD(N@v!#gfkwuaq2e0>-Nw zCC@nl+1^hvk8*tb_U-uA2^g-vD1)Dipt@Uf@=3^14@h2mQuq^+-#sacbx8I(CH!!4 zltoKRPCe_Yp?KjGgm?#v$5iypQF3e``nSOVHJk+w zZxan&I+TN=MvNmMEC`$0AV{CsBcn+=`nRgMuMPav4IIGJB1wHw@)vF5kkG4Mtg6px z2vg|=A^48dFwjQNv)ryeWtEE!r4`lYDyy@Yo<)AI$ynbq^Fnj=%~AR;xTATM^DXr^ zN9nC1qx#~?YV%FO2Fuk(r?xFtWT<>n;vARTgRtwg*wRf#nuqEc@pSx2a?%+XrEZs8 zeFoCiy^^<|5!-%A@)yKsCC8r?d{A=LS(u=jhjGo_#4{!TbQTiTVe#yf&WZX{5}2#c zL5A8YdB-`Js9ux&(>W+qha_^&r1yk3N#61vWU7x#{)l++2+oOkUp%?jlbG*%U#yv4 zBjUcA--lxT3}$&MYWhPs_B{{5`u35OK{f_Af3c>%2isLjQIch}rGp_`Q;^gyM{L zD1c@7Y&!(aT$e5`x=j34q|lbzeNzl&4b_V(>S~?ZjhLs5jr^^cmyD&}8pD7NaRg-z zyrTmm{FqnJwSeYeZIqJeEw8cy9UTy9@(6&97;w ztGE1jST0weUpeo$qH@7{aoTThkth)8u7qI@vY*^Jd9qQ;@jZnxK8mkar4Yh@v358iH)7& zqv+<>dU96c9jZN3VyQg(xTO#|3fB9zTyIavDb|=mB~z#L*0R4|GxMhORKem);+@V7;fnIC zdzMX{-g_e7oVcmHX?fi@#o2t)?Yg{)1*Y=e`QFqFyI1kV;+vM`!w*LHkyCTB%gQHF zf==9v#-}btHo}IHmm#T}#c?y>M>$GA_deNh{bd-Nxj+jPXV=o9JxoJ^Rz!nY3l@VG zO}x%B+tSanUrQttO6E1qKtrYFj7$9(0>gZoTMIns4~F~-zwU`p8WRJqWYPm+)UJxL z(Br7;;Rtv=$fpnlHE5sV$wVc?_X~=t?pRu(H^;u@iQ3l`^AX0XeZ$76eMibyDD8Wq zK1Bb-StFG2zSl?u@ciqzYJ^gK@4tAG_5%e1xy+B!{7hy5n_t)%HIFQzf~Xu24J1;C zf{8qd1`&A?4JOiMVOf%rY*U31pHv?T7(~FT`AVZF6TzmhH2uj$vhkPZHZpzL+%C-k zGEr=TqzNVy%_fA6Q5#GuMkppZ6I+GPcVZ2ODAU~2*6QxNaGm1?z8>2RoRJu@FD$!^`4O*Hz zn2x_BDDamO+#J%=V{5#{m3aoCokq!wh8F|nDz=~^zCLSd|0 zo-_qyirARg*u5uLSnJJoOPqz1Sr=i^NF~iSS>~5eeklh^rI|wJPBvxIOeIssW|}n9 z$&|C1AwVb2pm>(pbsNW>e3`s4XOQk5Kl$6VR5b7;_hqsSrk6OmrX75~BNw{y;QWP=mHq zpg84?WX!@?wI()3Z5e)^sze4Zr-+4fSGXf9F(OS#AFzrdRa9>^(St;5usTf%3VMV@ zEs3>63wZiE+?=MwjkS}hr`UR;2BOD^77}eBT12#wXtAIMZIeKH;W%1GDF&O9e`zfg zUn=5O4c+l4Nj*f9Hxo7T070FhK!BPv(h|AW*M93rFntO3O3uMd5I6} z!>nGGYC9#ZWV1t>on%(A*(J^EWLC3z1255S?2!Q%1s>tRpQU+=%y2gQq}fkqEt|hc zbAZe`Ht$IDS2B;XImpJS9VTTL%HF@cW|6thkXKz_S5bYx*;%;$s7j4J;(k(}OjpK7 z9H;chMS6qQ$`!OzayTQ0_wa{wCCt_?e_aqmVKZOv18FXjX<^gJ#;A3XLZP%vL{AW1 zCVEm(gZ43}X@4iPnJ0cC%@s0R*!)A9&&WI_Oq5moT&gcgJ+NMHzDQzrm z29Kt-*4kE2hGy$CZLM8wy&&EYQ`AJQZP(h?*4k%#T(kB)=Y{dI7yg*{_x;X!&v(A} zeZOJ;_Pp5*mp?wJ?@*U-yp#fKm*IhkPh?O^Syp3hc($6JR|Y#B>9! zg-(~t09Sz0eQ-|_jN{5)UPZ;@FeVk^T|);$2tW-MCqt1KUPy*1P~*Cu47YrhvC%lQ z7WCNP0tVOMEa>Y4yKwYq*e8bXM~hof!`M0omWkqdd62K{(!0C!c6H^!SRL3frV#GP z)Qlqd6{KKG5eyZ>wIaxX6j#4uSgR8ix0ga0cZZ&3kRz#QS-kOjAqxr zA6fMFba+cf?&cWRz(Y7-8wBE|L~dY24I32ks0*vX4o&z&HH;HO z_B_bNt^Fa$wR#>zdg4n-&<9&gkcw~5fFS(V1Ver7x{89S1*J}WJ_4d}%tDC9&IplK zuoe1Z(?S@4KgEjswk(A0%Gw0nTqE@C8c0`i`68djb2TtgF(zQVS&mmtqDo4w=rpkm z1}M`KFoxzG9xMuN6)M!B-7%DadPDf`M7kR7 z<|w?@3gK!Ei!H}Qv)%(ZZ^$u7Qc?1(V=&ZjY&3al(BMdWJsfL}i=+1GG0>~k9Ko-T zL9%Mo^X!N=7^Lo!T-XK)e%qL7cB13vo=C^&y*hlc4MP2{y8|hX_7UOuSsTQtJ~3S7 zr#2X=rVM1xISxf?rR4VG;yro!I1Ewex<#J4Nb)BqMKJk<=w-P(7>t$^FvNQei!8^@ zKlH`lpMb&Yrh77e78&XeDbi0uzWT;J!RJnj;P0dmHyERSEZO^%*!I4$++pb{Nb##> zrsqs>#MJe}9j74NyFp6F$%B#DN#bT4&B$_;wT5EgY3QfkW`Rkk#X?sM;-DxI@8}a1 zf-6r$h@ZDx#*uXNZd397(-5fEZ~#w z8gs>~C^yq@eC^T)5{>R)uZr5`_ZOjeSWfh>FfBFJ-5*_WH(F3zV{W)Vy2KsD*V-Y> zZ>{?}$#Ly`2zIrLt*9FGqr|CM;JcR;G@oyz$ z!VgO2{1-oiT=g-@mx*sl)}Isa_t|vLnR-t2OkXzQxt!;q)Gv#f4++-y7GK4F=f!gD z&!h~p@wll#3BfUe;Eg5c!GIs0hY0+hJ~p0*{+Qb-s8dkC;BrfSjm2cO(kABDFRHgJ zv@JrvP6)^2g-STu>0^2)6vAxW*$E+4=4^43+2W%_p+jN!&Cpla>Xubo9&>7UW1cdW z`QKt*GB)hN7zSN{5LMQ|iJ$UK+s1vCfibN#43|Ay54|PB29BjFC zcS`P6h{LV~CD0wMClg?}$LzccvDoxE1jTcgT)P)_Y1QU~hsR2%-K$`EZA16+RN?h& zpbxlT#eDG}D)6%SYUhGgm~**j>1Y1mrRV7XUiz85^s?wo{OB@_O6D8Pa~HMReJ9jg z8}xY=Q?-*Cr(cuZjnBixF7Y2m&cGR65G8pQuILi~CfU!zz3!Y@c(qG>r?cn%iNlpY zg137i#V!rRpgcu&8?jgMy>;~d%W1PlzUEs+$~37RQS}DPJ`XEa#~q;1J&-OvFj>}CKnXk>*Y#& zjW3%r`CcvGMX#!Y$?hsR@fw6S@=|a`dFDMUil^T@QO7T8_s>B2xSY*L-mWVsE-dQV z4EElf?^Rk{dS6>U{D$m+nglj%psL z{Or?wV!Q|axuQW?mD=Y}F1;BB?T8qS9!FIVN1uHmK1L8^(~jctG-ZVEF^Z|(vEfE< zjgJx~Nm?fbg1O8E zX)clJ$L4c32Ca)!2$j1+6iRfJD2(VDQGcQ@h$4u-%*Dw=m7$G)5@w}S`!fafB4E}2 zBF)!iV%U5m&9`I*vbiD6-^uv1xhc&LWMbL;Lz;h*iDUC48-sR>RJ>5yzxojlBD_tM zK=e~ChGi(x}_6r4m!v#D>akGjTj45n9rO}Z|Wuvk&Xg;LU@Vntk7&Ku48BV5>O_nqx$V_1~QkonxQ`zK7Gn&ja zHe(v48cS+At9)q+$jo3fUYa5@Rcwl-DIxO+n+ejCl9|b-Oqxk#X0e&f#-LS_nk|&w ze=M#Vt{BEmAyX}kHkD`|(KMp@MAL~D2(oE21d5aX2pN+wR&6F5gEkw#8LkWroyg`XCh(w$ACePJ&%VCck z_TjA&N>t;1dF2v=v6=7mo;2^1*}~=nHU{kgDHKZkkZ3E>M?~8M*|d*2O*=^DMV|Pn nG>6D+XLDGZqhxjn6KmC4rD`Mf5+@y(rd None: "LL1.yaml hash does not match generated.dat. Please regenerate using 'python worlds/lingo/utils/pickle_static_data.py'") self.assertEqual(ids_file_hash, HASHES["ids.yaml"], "ids.yaml hash does not match generated.dat. Please regenerate using 'python worlds/lingo/utils/pickle_static_data.py'") + + def test_panel_doors_are_set(self) -> None: + # This panel is defined earlier in the file than the panel door, so we want to check that the panel door is + # correctly applied. + self.assertNotEqual(PANELS_BY_ROOM["Outside The Agreeable"]["FIVE (1)"].panel_door, None) diff --git a/worlds/lingo/utils/pickle_static_data.py b/worlds/lingo/utils/pickle_static_data.py index cd5c4b41df4b..df82a12861a4 100644 --- a/worlds/lingo/utils/pickle_static_data.py +++ b/worlds/lingo/utils/pickle_static_data.py @@ -111,6 +111,16 @@ def load_static_data(ll1_path, ids_path): with open(ll1_path, "r") as file: config = Utils.parse_yaml(file) + # We have to process all panel doors first so that panels can see what panel doors they're in even if they're + # defined earlier in the file than the panel door. + for room_name, room_data in config.items(): + if "panel_doors" in room_data: + PANEL_DOORS_BY_ROOM[room_name] = dict() + + for panel_door_name, panel_door_data in room_data["panel_doors"].items(): + process_panel_door(room_name, panel_door_name, panel_door_data) + + # Process the rest of the room. for room_name, room_data in config.items(): process_room(room_name, room_data) @@ -515,12 +525,6 @@ def process_room(room_name, room_data): for source_room, doors in room_data["entrances"].items(): process_entrance(source_room, doors, room_obj) - if "panel_doors" in room_data: - PANEL_DOORS_BY_ROOM[room_name] = dict() - - for panel_door_name, panel_door_data in room_data["panel_doors"].items(): - process_panel_door(room_name, panel_door_name, panel_door_data) - if "panels" in room_data: PANELS_BY_ROOM[room_name] = dict() From 704f14ffcd80d32a4c0ffed4bdf4cf38324b0dc5 Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Tue, 10 Dec 2024 14:37:54 -0500 Subject: [PATCH 014/144] Core: Add toggles_as_bools to options.as_dict (#3770) * Add toggles_as_bools to options.as_dict * Update Options.py Co-authored-by: Doug Hoskisson * Add param to docstring * if -> elif --------- Co-authored-by: Doug Hoskisson --- Options.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Options.py b/Options.py index d3b2e6c1ba11..4e26a0d56c5c 100644 --- a/Options.py +++ b/Options.py @@ -754,7 +754,7 @@ def __init__(self, value: int) -> None: elif value > self.range_end and value not in self.special_range_names.values(): raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " + f"and is also not one of the supported named special values: {self.special_range_names}") - + # See docstring for key in self.special_range_names: if key != key.lower(): @@ -1180,7 +1180,7 @@ def __len__(self) -> int: class Accessibility(Choice): """ Set rules for reachability of your items/locations. - + **Full:** ensure everything can be reached and acquired. **Minimal:** ensure what is needed to reach your goal can be acquired. @@ -1198,7 +1198,7 @@ class Accessibility(Choice): class ItemsAccessibility(Accessibility): """ Set rules for reachability of your items/locations. - + **Full:** ensure everything can be reached and acquired. **Minimal:** ensure what is needed to reach your goal can be acquired. @@ -1249,12 +1249,16 @@ class CommonOptions(metaclass=OptionsMetaProperty): progression_balancing: ProgressionBalancing accessibility: Accessibility - def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]: + def as_dict(self, + *option_names: str, + casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake", + toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]: """ Returns a dictionary of [str, Option.value] :param option_names: names of the options to return :param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab` + :param toggles_as_bools: whether toggle options should be output as bools instead of strings """ assert option_names, "options.as_dict() was used without any option names." option_results = {} @@ -1276,6 +1280,8 @@ def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, value = getattr(self, option_name).value if isinstance(value, set): value = sorted(value) + elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle): + value = bool(value) option_results[display_name] = value else: raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}") From 54a0a5ac0002ff1edf858441891625600b69e812 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Tue, 10 Dec 2024 21:06:06 +0100 Subject: [PATCH 015/144] The Witness: Put progression + useful on some items. (#4027) * proguseful * ruff * variable rename * variable rename * Better (?) comment * Better way to do this? I guess * sure * ruff * Eh, it's not worth it. Here's the much simpler version * don't need this now * Improve some classification checks while we're at it * Only proguseful obelisk keys if eps are individual --- worlds/witness/player_items.py | 48 +++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/worlds/witness/player_items.py b/worlds/witness/player_items.py index 831e614f21c4..d1b951fa8e15 100644 --- a/worlds/witness/player_items.py +++ b/worlds/witness/player_items.py @@ -7,6 +7,7 @@ from BaseClasses import Item, ItemClassification, MultiWorld from .data import static_items as static_witness_items +from .data import static_logic as static_witness_logic from .data.item_definition_classes import ( DoorItemDefinition, ItemCategory, @@ -53,9 +54,8 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic, # Remove all progression items that aren't actually in the game. self.item_data = { name: data for (name, data) in self.item_data.items() - if data.classification not in - {ItemClassification.progression, ItemClassification.progression_skip_balancing} - or name in player_logic.PROGRESSION_ITEMS_ACTUALLY_IN_THE_GAME + if ItemClassification.progression not in data.classification + or name in player_logic.PROGRESSION_ITEMS_ACTUALLY_IN_THE_GAME } # Downgrade door items @@ -72,7 +72,7 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic, # Add progression items to the mandatory item list. progression_dict = { name: data for (name, data) in self.item_data.items() - if data.classification in {ItemClassification.progression, ItemClassification.progression_skip_balancing} + if ItemClassification.progression in data.classification } for item_name, item_data in progression_dict.items(): if isinstance(item_data.definition, ProgressiveItemDefinition): @@ -100,6 +100,46 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic, self.item_data[location_name] = ItemData(None, ItemDefinition(0, ItemCategory.EVENT), ItemClassification.progression, False) + # Determine which items should be progression + useful, if they exist in some capacity. + # Note: Some of these may need to be updated for the "independent symbols" PR. + self._proguseful_items = { + "Dots", "Stars", "Shapers", "Black/White Squares", + "Caves Shortcuts", "Caves Mountain Shortcut (Door)", "Caves Swamp Shortcut (Door)", + "Boat", + } + + if self._world.options.shuffle_EPs == "individual": + self._proguseful_items |= { + "Town Obelisk Key", # Most checks + "Monastery Obelisk Key", # Most sphere 1 checks, and also super dense ("Jackpot" vibes)} + } + + if self._world.options.shuffle_discarded_panels: + # Discards only give a moderate amount of checks, but are very spread out and a lot of them are in sphere 1. + # Thus, you really want to have the discard-unlocking item as quickly as possible. + + if self._world.options.puzzle_randomization in ("none", "sigma_normal"): + self._proguseful_items.add("Triangles") + elif self._world.options.puzzle_randomization == "sigma_expert": + self._proguseful_items.add("Arrows") + # Discards require two symbols in Variety, so the "sphere 1 unlocking power" of Arrows is not there. + if self._world.options.puzzle_randomization == "sigma_expert": + self._proguseful_items.add("Triangles") + self._proguseful_items.add("Full Dots") + self._proguseful_items.add("Stars + Same Colored Symbol") + self._proguseful_items.discard("Stars") # Stars are not that useful on their own. + if self._world.options.puzzle_randomization == "umbra_variety": + self._proguseful_items.add("Triangles") + + # This needs to be improved when the improved independent&progressive symbols PR is merged + for item in list(self._proguseful_items): + self._proguseful_items.add(static_witness_logic.get_parent_progressive_item(item)) + + for item_name, item_data in self.item_data.items(): + if item_name in self._proguseful_items: + item_data.classification |= ItemClassification.useful + + def get_mandatory_items(self) -> Dict[str, int]: """ Returns the list of items that must be in the pool for the game to successfully generate. From 9a37a136a1ab02c561b704a144b6424eac5db416 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Tue, 10 Dec 2024 21:13:45 +0100 Subject: [PATCH 016/144] The Witness: Add more panels to the "doors: panels" mode (#2916) * Add more panels that should be panels * Make it so the caves panel items don't exist in early caves * Remove unused import * oops * Remove Jungle to Monastery Garden from usefulification list * Add a basic test * ruff --------- Co-authored-by: Fabian Dill --- worlds/witness/data/WitnessItems.txt | 12 ++++++-- .../Door_Shuffle/Complex_Door_Panels.txt | 5 ++++ .../settings/Door_Shuffle/Simple_Panels.txt | 4 +-- worlds/witness/data/settings/Early_Caves.txt | 6 +++- .../data/settings/Early_Caves_Start.txt | 6 +++- worlds/witness/player_items.py | 7 +++-- worlds/witness/player_logic.py | 12 ++++++-- worlds/witness/test/test_door_shuffle.py | 28 ++++++++++++++++++- 8 files changed, 67 insertions(+), 13 deletions(-) diff --git a/worlds/witness/data/WitnessItems.txt b/worlds/witness/data/WitnessItems.txt index 782fa9c3d226..57aee28e45b6 100644 --- a/worlds/witness/data/WitnessItems.txt +++ b/worlds/witness/data/WitnessItems.txt @@ -56,6 +56,7 @@ Doors: 1119 - Quarry Stoneworks Entry (Panel) - 0x01E5A,0x01E59 1120 - Quarry Stoneworks Ramp Controls (Panel) - 0x03678,0x03676 1122 - Quarry Stoneworks Lift Controls (Panel) - 0x03679,0x03675 +1123 - Quarry Stoneworks Stairs (Panel) - 0x03677 1125 - Quarry Boathouse Ramp Height Control (Panel) - 0x03852 1127 - Quarry Boathouse Ramp Horizontal Control (Panel) - 0x03858 1129 - Quarry Boathouse Hook Control (Panel) - 0x275FA @@ -84,6 +85,7 @@ Doors: 1205 - Treehouse Laser House Door Timer (Panel) - 0x2700B,0x17CBC 1208 - Treehouse Drawbridge (Panel) - 0x037FF 1175 - Jungle Popup Wall (Panel) - 0x17CAB +1178 - Jungle Monastery Garden Shortcut (Panel) - 0x17CAA 1180 - Bunker Entry (Panel) - 0x17C2E 1183 - Bunker Tinted Glass Door (Panel) - 0x0A099 1186 - Bunker Elevator Control (Panel) - 0x0A079 @@ -94,12 +96,15 @@ Doors: 1195 - Swamp Rotating Bridge (Panel) - 0x181F5 1196 - Swamp Long Bridge (Panel) - 0x17E2B 1197 - Swamp Maze Controls (Panel) - 0x17C0A,0x17E07 +1199 - Swamp Laser Shortcut (Panel) - 0x17C05 1220 - Mountain Floor 1 Light Bridge (Panel) - 0x09E39 1225 - Mountain Floor 2 Light Bridge Near (Panel) - 0x09E86 1230 - Mountain Floor 2 Light Bridge Far (Panel) - 0x09ED8 1235 - Mountain Floor 2 Elevator Control (Panel) - 0x09EEB 1240 - Caves Entry (Panel) - 0x00FF8 1242 - Caves Elevator Controls (Panel) - 0x335AB,0x335AC,0x3369D +1243 - Caves Mountain Shortcut (Panel) - 0x021D7 +1244 - Caves Swamp Shortcut (Panel) - 0x17CF2 1245 - Challenge Entry (Panel) - 0x0A16E 1250 - Tunnels Entry (Panel) - 0x039B4 1255 - Tunnels Town Shortcut (Panel) - 0x09E85 @@ -250,19 +255,20 @@ Doors: 2101 - Outside Tutorial Outpost Panels - 0x0A171,0x04CA4 2105 - Desert Panels - 0x09FAA,0x1C2DF,0x1831E,0x1C260,0x1831C,0x1C2F3,0x1831D,0x1C2B1,0x1831B,0x0C339,0x0A249,0x0A015,0x09FA0,0x09F86 2110 - Quarry Outside Panels - 0x17C09,0x09E57,0x17CC4 -2115 - Quarry Stoneworks Panels - 0x01E5A,0x01E59,0x03678,0x03676,0x03679,0x03675 +2115 - Quarry Stoneworks Panels - 0x01E5A,0x01E59,0x03678,0x03676,0x03679,0x03675,0x03677 2120 - Quarry Boathouse Panels - 0x03852,0x03858,0x275FA 2122 - Keep Hedge Maze Panels - 0x00139,0x019DC,0x019E7,0x01A0F 2125 - Monastery Panels - 0x09D9B,0x00C92,0x00B10 +2127 - Jungle Panels - 0x17CAB,0x17CAA 2130 - Town Church & RGB House Panels - 0x28998,0x28A0D,0x334D8 2135 - Town Maze Panels - 0x2896A,0x28A79 2137 - Town Dockside House Panels - 0x0A0C8,0x09F98 2140 - Windmill & Theater Panels - 0x17D02,0x00815,0x17F5F,0x17F89,0x0A168,0x33AB2 2145 - Treehouse Panels - 0x0A182,0x0288C,0x02886,0x2700B,0x17CBC,0x037FF 2150 - Bunker Panels - 0x34BC5,0x34BC6,0x0A079,0x0A099,0x17C2E -2155 - Swamp Panels - 0x00609,0x18488,0x181F5,0x17E2B,0x17C0A,0x17E07,0x17C0D,0x0056E +2155 - Swamp Panels - 0x00609,0x18488,0x181F5,0x17E2B,0x17C0A,0x17E07,0x17C0D,0x0056E,0x17C05 2160 - Mountain Panels - 0x09ED8,0x09E86,0x09E39,0x09EEB -2165 - Caves Panels - 0x3369D,0x00FF8,0x0A16E,0x335AB,0x335AC +2165 - Caves Panels - 0x3369D,0x00FF8,0x0A16E,0x335AB,0x335AC,0x021D7,0x17CF2 2170 - Tunnels Panels - 0x09E85,0x039B4 2200 - Desert Obelisk Key - 0x0332B,0x03367,0x28B8A,0x037B6,0x037B2,0x000F7,0x3351D,0x0053C,0x00771,0x335C8,0x335C9,0x337F8,0x037BB,0x220E4,0x220E5,0x334B9,0x334BC,0x22106,0x0A14C,0x0A14D,0x00359 diff --git a/worlds/witness/data/settings/Door_Shuffle/Complex_Door_Panels.txt b/worlds/witness/data/settings/Door_Shuffle/Complex_Door_Panels.txt index 63d8a58d2676..6c3b328691f9 100644 --- a/worlds/witness/data/settings/Door_Shuffle/Complex_Door_Panels.txt +++ b/worlds/witness/data/settings/Door_Shuffle/Complex_Door_Panels.txt @@ -9,6 +9,7 @@ Desert Flood Room Entry (Panel) Quarry Entry 1 (Panel) Quarry Entry 2 (Panel) Quarry Stoneworks Entry (Panel) +Quarry Stoneworks Stairs (Panel) Shadows Door Timer (Panel) Keep Hedge Maze 1 (Panel) Keep Hedge Maze 2 (Panel) @@ -28,11 +29,15 @@ Treehouse Third Door (Panel) Treehouse Laser House Door Timer (Panel) Treehouse Drawbridge (Panel) Jungle Popup Wall (Panel) +Jungle Monastery Garden Shortcut (Panel) Bunker Entry (Panel) Bunker Tinted Glass Door (Panel) Swamp Entry (Panel) Swamp Platform Shortcut (Panel) +Swamp Laser Shortcut (Panel) Caves Entry (Panel) +Caves Mountain Shortcut (Panel) +Caves Swamp Shortcut (Panel) Challenge Entry (Panel) Tunnels Entry (Panel) Tunnels Town Shortcut (Panel) \ No newline at end of file diff --git a/worlds/witness/data/settings/Door_Shuffle/Simple_Panels.txt b/worlds/witness/data/settings/Door_Shuffle/Simple_Panels.txt index 23501d20d3a7..f9b8b1b43ae7 100644 --- a/worlds/witness/data/settings/Door_Shuffle/Simple_Panels.txt +++ b/worlds/witness/data/settings/Door_Shuffle/Simple_Panels.txt @@ -7,6 +7,7 @@ Quarry Stoneworks Panels Quarry Boathouse Panels Keep Hedge Maze Panels Monastery Panels +Jungle Panels Town Church & RGB House Panels Town Maze Panels Windmill & Theater Panels @@ -18,5 +19,4 @@ Mountain Panels Caves Panels Tunnels Panels Glass Factory Entry (Panel) -Shadows Door Timer (Panel) -Jungle Popup Wall (Panel) \ No newline at end of file +Shadows Door Timer (Panel) \ No newline at end of file diff --git a/worlds/witness/data/settings/Early_Caves.txt b/worlds/witness/data/settings/Early_Caves.txt index 48c8056bc7b6..df1e7b114a47 100644 --- a/worlds/witness/data/settings/Early_Caves.txt +++ b/worlds/witness/data/settings/Early_Caves.txt @@ -3,4 +3,8 @@ Caves Shortcuts Remove Items: Caves Mountain Shortcut (Door) -Caves Swamp Shortcut (Door) \ No newline at end of file +Caves Swamp Shortcut (Door) + +Forbidden Doors: +0x021D7 (Caves Mountain Shortcut Panel) +0x17CF2 (Caves Swamp Shortcut Panel) \ No newline at end of file diff --git a/worlds/witness/data/settings/Early_Caves_Start.txt b/worlds/witness/data/settings/Early_Caves_Start.txt index a16a6d02bb9f..bc79007fa54b 100644 --- a/worlds/witness/data/settings/Early_Caves_Start.txt +++ b/worlds/witness/data/settings/Early_Caves_Start.txt @@ -6,4 +6,8 @@ Caves Shortcuts Remove Items: Caves Mountain Shortcut (Door) -Caves Swamp Shortcut (Door) \ No newline at end of file +Caves Swamp Shortcut (Door) + +Forbidden Doors: +0x021D7 (Caves Mountain Shortcut Panel) +0x17CF2 (Caves Swamp Shortcut Panel) \ No newline at end of file diff --git a/worlds/witness/player_items.py b/worlds/witness/player_items.py index d1b951fa8e15..2fb987bb456a 100644 --- a/worlds/witness/player_items.py +++ b/worlds/witness/player_items.py @@ -227,10 +227,13 @@ def get_door_ids_in_pool(self) -> List[int]: Returns the total set of all door IDs that are controlled by items in the pool. """ output: List[int] = [] - for item_name, item_data in dict(self.item_data.items()).items(): + + for item_name, item_data in self.item_data.items(): if not isinstance(item_data.definition, DoorItemDefinition): continue - output += [int(hex_string, 16) for hex_string in item_data.definition.panel_id_hexes] + + output += [int(hex_string, 16) for hex_string in item_data.definition.panel_id_hexes + if hex_string not in self._logic.FORBIDDEN_DOORS] return output diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 58f15532f58c..9e6c9597382b 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -82,6 +82,7 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in self.PARENT_ITEM_COUNT_PER_BASE_ITEM: Dict[str, int] = defaultdict(lambda: 1) self.PROGRESSIVE_LISTS: Dict[str, List[str]] = {} self.DOOR_ITEMS_BY_ID: Dict[str, List[str]] = {} + self.FORBIDDEN_DOORS: Set[str] = set() self.STARTING_INVENTORY: Set[str] = set() @@ -192,8 +193,9 @@ def reduce_req_within_region(self, entity_hex: str) -> WitnessRule: for subset in these_items: self.BASE_PROGESSION_ITEMS_ACTUALLY_IN_THE_GAME.update(subset) - # Handle door entities (door shuffle) - if entity_hex in self.DOOR_ITEMS_BY_ID: + # If this entity is opened by a door item that exists in the itempool, add that item to its requirements. + # Also, remove any original power requirements this entity might have had. + if entity_hex in self.DOOR_ITEMS_BY_ID and entity_hex not in self.FORBIDDEN_DOORS: # If this entity is opened by a door item that exists in the itempool, add that item to its requirements. door_items = frozenset({frozenset([item]) for item in self.DOOR_ITEMS_BY_ID[entity_hex]}) @@ -329,6 +331,10 @@ def make_single_adjustment(self, adj_type: str, line: str) -> None: if entity_hex in self.DOOR_ITEMS_BY_ID and item_name in self.DOOR_ITEMS_BY_ID[entity_hex]: self.DOOR_ITEMS_BY_ID[entity_hex].remove(item_name) + if adj_type == "Forbidden Doors": + entity_hex = line[:7] + self.FORBIDDEN_DOORS.add(entity_hex) + if adj_type == "Starting Inventory": self.STARTING_INVENTORY.add(line) @@ -704,7 +710,7 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: self.make_single_adjustment(current_adjustment_type, line) - for entity_id in self.COMPLETELY_DISABLED_ENTITIES: + for entity_id in self.COMPLETELY_DISABLED_ENTITIES | self.FORBIDDEN_DOORS: if entity_id in self.DOOR_ITEMS_BY_ID: del self.DOOR_ITEMS_BY_ID[entity_id] diff --git a/worlds/witness/test/test_door_shuffle.py b/worlds/witness/test/test_door_shuffle.py index 0e38c32d69e2..d593a84bdb8f 100644 --- a/worlds/witness/test/test_door_shuffle.py +++ b/worlds/witness/test/test_door_shuffle.py @@ -1,4 +1,4 @@ -from ..test import WitnessTestBase +from ..test import WitnessMultiworldTestBase, WitnessTestBase class TestIndividualDoors(WitnessTestBase): @@ -22,3 +22,29 @@ def test_swamp_laser_shortcut(self) -> None: ], only_check_listed=True, ) + + +class TestForbiddenDoors(WitnessMultiworldTestBase): + options_per_world = [ + { + "early_caves": "off", + }, + { + "early_caves": "add_to_pool", + }, + ] + + common_options = { + "shuffle_doors": "panels", + "shuffle_postgame": True, + } + + def test_forbidden_doors(self) -> None: + self.assertTrue( + self.get_items_by_name("Caves Mountain Shortcut (Panel)", 1), + "Caves Mountain Shortcut (Panel) should exist in panels shuffle, but it didn't." + ) + self.assertFalse( + self.get_items_by_name("Caves Mountain Shortcut (Panel)", 2), + "Caves Mountain Shortcut (Panel) should be removed when Early Caves is enabled, but it still exists." + ) From 3c5ec49dbee129579573ab7528ddb87185f9fc9a Mon Sep 17 00:00:00 2001 From: Jouramie <16137441+Jouramie@users.noreply.github.com> Date: Thu, 12 Dec 2024 03:17:19 -0500 Subject: [PATCH 017/144] Stardew Valley: Fix potential incompletable seed when starting winter (#4361) * make moss available with any season except winter * add tool and region requirement for moss --- worlds/stardew_valley/logic/logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/stardew_valley/logic/logic.py b/worlds/stardew_valley/logic/logic.py index 9d4447439f7b..6efc1ade4980 100644 --- a/worlds/stardew_valley/logic/logic.py +++ b/worlds/stardew_valley/logic/logic.py @@ -281,7 +281,7 @@ def __init__(self, player: int, options: StardewValleyOptions, content: StardewC Material.coal: self.mine.can_mine_in_the_mines_floor_41_80() | self.tool.has_tool(Tool.pan), Material.fiber: True_(), Material.hardwood: self.tool.has_tool(Tool.axe, ToolMaterial.copper) & (self.region.can_reach(Region.secret_woods) | self.region.can_reach(Region.island_west)), - Material.moss: True_(), + Material.moss: self.season.has_any_not_winter() & (self.tool.has_tool(Tool.scythe) | self.combat.has_any_weapon) & self.region.can_reach(Region.forest), Material.sap: self.ability.can_chop_trees(), Material.stone: self.tool.has_tool(Tool.pickaxe), Material.wood: self.tool.has_tool(Tool.axe), From f91537fb481e58ff429929f7919a288f3630ba04 Mon Sep 17 00:00:00 2001 From: Justus Lind Date: Thu, 12 Dec 2024 18:18:19 +1000 Subject: [PATCH 018/144] Muse Dash: Remove bad option defaults. #4340 --- worlds/musedash/Options.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/worlds/musedash/Options.py b/worlds/musedash/Options.py index e647c18d7096..b8c969c39b0f 100644 --- a/worlds/musedash/Options.py +++ b/worlds/musedash/Options.py @@ -11,7 +11,6 @@ class DLCMusicPacks(OptionSet): Note: The [Just As Planned] DLC contains all [Muse Plus] songs. """ display_name = "DLC Packs" - default = {} valid_keys = [dlc for dlc in MuseDashCollections.DLC] @@ -142,7 +141,6 @@ class ChosenTraps(OptionSet): Note: SFX traps are only available if [Just as Planned] DLC songs are enabled. """ display_name = "Chosen Traps" - default = {} valid_keys = {trap for trap in MuseDashCollections.trap_items.keys()} From 7d0b701a2df01b93cf292c231d38c4753e6f0715 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Thu, 12 Dec 2024 06:54:03 -0500 Subject: [PATCH 019/144] TUNIC: Change rule for heir access in non-hex quest #4365 --- worlds/tunic/er_rules.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index d5d6f16c57ec..786af0d617a8 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -1079,7 +1079,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ connecting_region=regions["Spirit Arena Victory"], rule=lambda state: (state.has(gold_hexagon, player, world.options.hexagon_goal.value) if world.options.hexagon_quest else - (state.has_all({red_hexagon, green_hexagon, blue_hexagon, "Unseal the Heir"}, player) + (state.has("Unseal the Heir", player) and state.has_group_unique("Hero Relics", player, 6) and has_sword(state, player)))) @@ -1447,6 +1447,9 @@ def set_er_location_rules(world: "TunicWorld") -> None: lambda state: has_ability(prayer, state, world)) set_rule(world.get_location("Library Fuse"), lambda state: has_ability(prayer, state, world) and has_ladder("Ladders in Library", state, world)) + if not world.options.hexagon_quest: + set_rule(world.get_location("Place Questagons"), + lambda state: state.has_all((red_hexagon, blue_hexagon, green_hexagon), player)) # Bombable Walls for location_name in bomb_walls: From 3acbe9ece14ba792ea0c50bf3623e0e377cb18f3 Mon Sep 17 00:00:00 2001 From: LiquidCat64 <74896918+LiquidCat64@users.noreply.github.com> Date: Thu, 12 Dec 2024 06:47:47 -0700 Subject: [PATCH 020/144] Castlevania: Circle of the Moon - Implement New Game (#3299) * Add the cotm package with working seed playthrough generation. * Add the proper event flag IDs for the Item codes. * Oooops. Put the world completion condition in! * Adjust the game name and abbreviations. * Implement more settings. * Account for too many start_inventory_from_pool cards with Halve DSS Cards Placed. * Working (albeit very sloooooooooooow) ROM patching. * Screw you, bsdiff! AP Procedure Patch for life! * Nuke stage_assert_generate as the ROM is no longer needed for that. * Working item writing and position adjusting. * Fix the magic item graphics in Locations wherein they can be fixed. * Enable sub-weapon shuffle * Get the seed display working. * Get the enemy item drop randomization working. Phew! * Enemy drop rando and seed display fixes. * Functional Countdown + Early Double setting * Working multiworld (yay!) * Fix item links and demo shenanigans. * Add Wii U VC hash and a docs section explaining the rereleases. * Change all client read/writes to EWRAM instead of Combined WRAM. * Custom text insertion foundations. * Working text converter and word wrap detector. * More refinements to the text wrap system. * Well and truly working sent/received messages. * Add DeathLink and Battle Arena goal options. * Add tracker stuff, unittests, all locations countdown, presets. * Add to README, CODEOWNERS, and inno_setup * Add to README, CODEOWNERS, and inno_setup * Address some suggestions/problems. * Switch the Items and Locations to using dataclasses. * Add note about the alternate classes to the Game Page. * Oooops, typo! * Touch up the Options descriptions. * Fix Battle Arena flag being detected incorrectly on connection and name the locked location/item pairs better. * Implement option groups * Swap the Lizard-man Locations into their correct Regions. * Local start inventory, better DeathLink message handling, handle receiving over 255 of an item. * Update the PopTracker pack links to no longer point to the Releases page. * Add Skip Dialogues option. * Update the presets for the accessibility rework. * Swap the choices in the accessibility preset options. * Uhhhhhhh...just see the apworld v4 changelog for this one. * Ooops, typo! * . * Bunch of small stuff * Correctly change "Fake" to "Breakable" in this comment. Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Make can_touch_water one line. Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Make broke_iron_maidens one line. Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Fix majors countdown and make can_open_ceremonial_door one line. * Make the Trap AP Item less obvious. * Add Progression + Useful stuff, patcher handling for incompatible versions, and fix some mypy stuff. * Better option groups. * Change Early Double to Early Escape Item. * Update DeathLink description and ditch the Menu region. * Fix the Start Broken choice for Iron Maiden Behavior * Remove the forced option change with Arena goal + required All Bosses and Arena. * Update the Game Page with the removal of the forced option combination change. * Fix client potential to send packets nonstop. * More review addressing. * Fix the new select_drop code. * Fix the new select_drop code for REAL this time. * Send another LocationScout if we send Location checks without having the Location info. --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Co-authored-by: Exempt-Medic Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> --- README.md | 1 + docs/CODEOWNERS | 3 + inno_setup.iss | 5 + worlds/cvcotm/LICENSES.txt | 248 ++++++ worlds/cvcotm/NOTICE.txt | 4 + worlds/cvcotm/__init__.py | 221 +++++ worlds/cvcotm/aesthetics.py | 761 ++++++++++++++++ worlds/cvcotm/client.py | 563 ++++++++++++ worlds/cvcotm/cvcotm_text.py | 178 ++++ worlds/cvcotm/data/iname.py | 36 + worlds/cvcotm/data/ips/AllowAlwaysDrop.ips | Bin 0 -> 67 bytes worlds/cvcotm/data/ips/AllowSpeedDash.ips | Bin 0 -> 66 bytes worlds/cvcotm/data/ips/BrokenMaidens.ips | Bin 0 -> 54 bytes worlds/cvcotm/data/ips/BuffFamiliars.ips | Bin 0 -> 44 bytes worlds/cvcotm/data/ips/BuffSubweapons.ips | Bin 0 -> 68 bytes worlds/cvcotm/data/ips/CandleFix.ips | Bin 0 -> 20 bytes worlds/cvcotm/data/ips/CardCombosRevealed.ips | Bin 0 -> 17 bytes worlds/cvcotm/data/ips/CardUp_v3_Custom2.ips | Bin 0 -> 348 bytes worlds/cvcotm/data/ips/Countdown.ips | Bin 0 -> 240 bytes worlds/cvcotm/data/ips/DSSGlitchFix.ips | Bin 0 -> 95 bytes worlds/cvcotm/data/ips/DSSRunSpeed.ips | Bin 0 -> 18 bytes worlds/cvcotm/data/ips/DemoForceFirst.ips | Bin 0 -> 15 bytes .../data/ips/DropReworkMultiEdition.ips | Bin 0 -> 783 bytes worlds/cvcotm/data/ips/GameClearBypass.ips | Bin 0 -> 29 bytes worlds/cvcotm/data/ips/MPComboFix.ips | Bin 0 -> 15 bytes worlds/cvcotm/data/ips/MapEdits.ips | Bin 0 -> 32 bytes worlds/cvcotm/data/ips/MultiLastKey.ips | Bin 0 -> 191 bytes worlds/cvcotm/data/ips/NoDSSDrops.ips | Bin 0 -> 232 bytes worlds/cvcotm/data/ips/NoMPDrain.ips | Bin 0 -> 17 bytes worlds/cvcotm/data/ips/PermanentDash.ips | Bin 0 -> 33 bytes .../cvcotm/data/ips/SeedDisplay20Digits.ips | Bin 0 -> 110 bytes worlds/cvcotm/data/ips/ShooterStrength.ips | Bin 0 -> 16 bytes worlds/cvcotm/data/ips/SoftlockBlockFix.ips | Bin 0 -> 15 bytes worlds/cvcotm/data/lname.py | 128 +++ worlds/cvcotm/data/patches.py | 431 ++++++++++ .../en_Castlevania - Circle of the Moon.md | 169 ++++ worlds/cvcotm/docs/setup_en.md | 72 ++ worlds/cvcotm/items.py | 211 +++++ worlds/cvcotm/locations.py | 265 ++++++ worlds/cvcotm/lz10.py | 265 ++++++ worlds/cvcotm/options.py | 282 ++++++ worlds/cvcotm/presets.py | 190 ++++ worlds/cvcotm/regions.py | 189 ++++ worlds/cvcotm/rom.py | 600 +++++++++++++ worlds/cvcotm/rules.py | 203 +++++ worlds/cvcotm/test/__init__.py | 5 + worlds/cvcotm/test/test_access.py | 811 ++++++++++++++++++ 47 files changed, 5841 insertions(+) create mode 100644 worlds/cvcotm/LICENSES.txt create mode 100644 worlds/cvcotm/NOTICE.txt create mode 100644 worlds/cvcotm/__init__.py create mode 100644 worlds/cvcotm/aesthetics.py create mode 100644 worlds/cvcotm/client.py create mode 100644 worlds/cvcotm/cvcotm_text.py create mode 100644 worlds/cvcotm/data/iname.py create mode 100644 worlds/cvcotm/data/ips/AllowAlwaysDrop.ips create mode 100644 worlds/cvcotm/data/ips/AllowSpeedDash.ips create mode 100644 worlds/cvcotm/data/ips/BrokenMaidens.ips create mode 100644 worlds/cvcotm/data/ips/BuffFamiliars.ips create mode 100644 worlds/cvcotm/data/ips/BuffSubweapons.ips create mode 100644 worlds/cvcotm/data/ips/CandleFix.ips create mode 100644 worlds/cvcotm/data/ips/CardCombosRevealed.ips create mode 100644 worlds/cvcotm/data/ips/CardUp_v3_Custom2.ips create mode 100644 worlds/cvcotm/data/ips/Countdown.ips create mode 100644 worlds/cvcotm/data/ips/DSSGlitchFix.ips create mode 100644 worlds/cvcotm/data/ips/DSSRunSpeed.ips create mode 100644 worlds/cvcotm/data/ips/DemoForceFirst.ips create mode 100644 worlds/cvcotm/data/ips/DropReworkMultiEdition.ips create mode 100644 worlds/cvcotm/data/ips/GameClearBypass.ips create mode 100644 worlds/cvcotm/data/ips/MPComboFix.ips create mode 100644 worlds/cvcotm/data/ips/MapEdits.ips create mode 100644 worlds/cvcotm/data/ips/MultiLastKey.ips create mode 100644 worlds/cvcotm/data/ips/NoDSSDrops.ips create mode 100644 worlds/cvcotm/data/ips/NoMPDrain.ips create mode 100644 worlds/cvcotm/data/ips/PermanentDash.ips create mode 100644 worlds/cvcotm/data/ips/SeedDisplay20Digits.ips create mode 100644 worlds/cvcotm/data/ips/ShooterStrength.ips create mode 100644 worlds/cvcotm/data/ips/SoftlockBlockFix.ips create mode 100644 worlds/cvcotm/data/lname.py create mode 100644 worlds/cvcotm/data/patches.py create mode 100644 worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md create mode 100644 worlds/cvcotm/docs/setup_en.md create mode 100644 worlds/cvcotm/items.py create mode 100644 worlds/cvcotm/locations.py create mode 100644 worlds/cvcotm/lz10.py create mode 100644 worlds/cvcotm/options.py create mode 100644 worlds/cvcotm/presets.py create mode 100644 worlds/cvcotm/regions.py create mode 100644 worlds/cvcotm/rom.py create mode 100644 worlds/cvcotm/rules.py create mode 100644 worlds/cvcotm/test/__init__.py create mode 100644 worlds/cvcotm/test/test_access.py diff --git a/README.md b/README.md index 21a6faaa2698..36b7a07fb4b3 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Currently, the following games are supported: * Yacht Dice * Faxanadu * Saving Princess +* Castlevania: Circle of the Moon For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index 1aec57fc90f6..8b39f96068af 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -36,6 +36,9 @@ # Castlevania 64 /worlds/cv64/ @LiquidCat64 +# Castlevania: Circle of the Moon +/worlds/cvcotm/ @LiquidCat64 + # Celeste 64 /worlds/celeste64/ @PoryGone diff --git a/inno_setup.iss b/inno_setup.iss index 38e655d917c1..eb794650f3a6 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -186,6 +186,11 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: ".apcvcotm"; ValueData: "{#MyAppName}cvcotmpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}cvcotmpatch"; ValueData: "Archipelago Castlevania Circle of the Moon Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}cvcotmpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}cvcotmpatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; + Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; diff --git a/worlds/cvcotm/LICENSES.txt b/worlds/cvcotm/LICENSES.txt new file mode 100644 index 000000000000..815e52d5f668 --- /dev/null +++ b/worlds/cvcotm/LICENSES.txt @@ -0,0 +1,248 @@ + +Regarding the sprite data specifically for the Archipelago logo found in data > patches.py: + +The Archipelago Logo is © 2022 by Krista Corkos and Christopher Wilson and licensed under Attribution-NonCommercial 4.0 +International. Logo modified by Liquid Cat to fit artstyle and uses within this mod. To view a copy of this license, +visit http://creativecommons.org/licenses/by-nc/4.0/ + +The other custom sprites that I made, as long as you don't lie by claiming you were the one who drew them, I am fine +with you using and distributing them however you want to. -Liquid Cat + +======================================================================================================================== + +For the lz10.py and cvcotm_text.py modules specifically the MIT license applies. Its terms are as follows: + +MIT License + +cvcotm_text.py Copyright (c) 2024 Liquid Cat +(Please consider the associated pixel data for the ASCII characters missing from CotM in data > patches.py +in the public domain, if there was any thought that that could even be copyrighted. -Liquid Cat) + +lz10.py Copyright (c) 2024 lilDavid, NoiseCrush + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +======================================================================================================================== + +Everything else in this world package not mentioned above can be assumed covered by standalone CotMR's Apache license +being a piece of a direct derivative of it. The terms are as follows: + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021-2024 DevAnj, fusecavator, spooky, Malaert64 + + Archipelago version by Liquid Cat + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/worlds/cvcotm/NOTICE.txt b/worlds/cvcotm/NOTICE.txt new file mode 100644 index 000000000000..7a6f4d10ff5c --- /dev/null +++ b/worlds/cvcotm/NOTICE.txt @@ -0,0 +1,4 @@ +Circle of the Moon Randomizer +Copyright 2021-2024 DevAnj, fusecavator, spooky, Malaert64 + +Archipelago version by Liquid Cat \ No newline at end of file diff --git a/worlds/cvcotm/__init__.py b/worlds/cvcotm/__init__.py new file mode 100644 index 000000000000..4466ed79bdd2 --- /dev/null +++ b/worlds/cvcotm/__init__.py @@ -0,0 +1,221 @@ +import os +import typing +import settings +import base64 +import logging + +from BaseClasses import Item, Region, Tutorial, ItemClassification +from .items import CVCotMItem, FILLER_ITEM_NAMES, ACTION_CARDS, ATTRIBUTE_CARDS, cvcotm_item_info, \ + get_item_names_to_ids, get_item_counts +from .locations import CVCotMLocation, get_location_names_to_ids, BASE_ID, get_named_locations_data, \ + get_location_name_groups +from .options import cvcotm_option_groups, CVCotMOptions, SubWeaponShuffle, IronMaidenBehavior, RequiredSkirmishes, \ + CompletionGoal, EarlyEscapeItem +from .regions import get_region_info, get_all_region_names +from .rules import CVCotMRules +from .data import iname, lname +from .presets import cvcotm_options_presets +from worlds.AutoWorld import WebWorld, World + +from .aesthetics import shuffle_sub_weapons, get_location_data, get_countdown_flags, populate_enemy_drops, \ + get_start_inventory_data +from .rom import RomData, patch_rom, get_base_rom_path, CVCotMProcedurePatch, CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, \ + CVCOTM_VC_US_HASH +from .client import CastlevaniaCotMClient + + +class CVCotMSettings(settings.Group): + class RomFile(settings.UserFilePath): + """File name of the Castlevania CotM US rom""" + copy_to = "Castlevania - Circle of the Moon (USA).gba" + description = "Castlevania CotM (US) ROM File" + md5s = [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH] + + rom_file: RomFile = RomFile(RomFile.copy_to) + + +class CVCotMWeb(WebWorld): + theme = "stone" + options_presets = cvcotm_options_presets + + tutorials = [Tutorial( + "Multiworld Setup Guide", + "A guide to setting up the Archipleago Castlevania: Circle of the Moon randomizer on your computer and " + "connecting it to a multiworld.", + "English", + "setup_en.md", + "setup/en", + ["Liquid Cat"] + )] + + option_groups = cvcotm_option_groups + + +class CVCotMWorld(World): + """ + Castlevania: Circle of the Moon is a launch title for the Game Boy Advance and the first of three Castlevania games + released for the handheld in the "Metroidvania" format. As Nathan Graves, wielding the Hunter Whip and utilizing the + Dual Set-Up System for new possibilities, you must battle your way through Camilla's castle and rescue your master + from a demonic ritual to restore the Count's power... + """ + game = "Castlevania - Circle of the Moon" + item_name_groups = { + "DSS": ACTION_CARDS.union(ATTRIBUTE_CARDS), + "Card": ACTION_CARDS.union(ATTRIBUTE_CARDS), + "Action": ACTION_CARDS, + "Action Card": ACTION_CARDS, + "Attribute": ATTRIBUTE_CARDS, + "Attribute Card": ATTRIBUTE_CARDS, + "Freeze": {iname.serpent, iname.cockatrice, iname.mercury, iname.mars}, + "Freeze Action": {iname.mercury, iname.mars}, + "Freeze Attribute": {iname.serpent, iname.cockatrice} + } + location_name_groups = get_location_name_groups() + options_dataclass = CVCotMOptions + options: CVCotMOptions + settings: typing.ClassVar[CVCotMSettings] + origin_region_name = "Catacomb" + hint_blacklist = frozenset({lname.ba24}) # The Battle Arena reward, if it's put in, will always be a Last Key. + + item_name_to_id = {name: cvcotm_item_info[name].code + BASE_ID for name in cvcotm_item_info + if cvcotm_item_info[name].code is not None} + location_name_to_id = get_location_names_to_ids() + + # Default values to possibly be updated in generate_early + total_last_keys: int = 0 + required_last_keys: int = 0 + + auth: bytearray + + web = CVCotMWeb() + + def generate_early(self) -> None: + # Generate the player's unique authentication + self.auth = bytearray(self.random.getrandbits(8) for _ in range(16)) + + # If Required Skirmishes are on, force the Required and Available Last Keys to 8 or 9 depending on which option + # was chosen. + if self.options.required_skirmishes == RequiredSkirmishes.option_all_bosses: + self.options.required_last_keys.value = 8 + self.options.available_last_keys.value = 8 + elif self.options.required_skirmishes == RequiredSkirmishes.option_all_bosses_and_arena: + self.options.required_last_keys.value = 9 + self.options.available_last_keys.value = 9 + + self.total_last_keys = self.options.available_last_keys.value + self.required_last_keys = self.options.required_last_keys.value + + # If there are more Last Keys required than there are Last Keys in total, drop the required Last Keys to + # the total Last Keys. + if self.required_last_keys > self.total_last_keys: + self.required_last_keys = self.total_last_keys + logging.warning(f"[{self.player_name}] The Required Last Keys " + f"({self.options.required_last_keys.value}) is higher than the Available Last Keys " + f"({self.options.available_last_keys.value}). Lowering the required number to: " + f"{self.required_last_keys}") + self.options.required_last_keys.value = self.required_last_keys + + # Place the Double or Roc Wing in local_early_items if the Early Escape option is being used. + if self.options.early_escape_item == EarlyEscapeItem.option_double: + self.multiworld.local_early_items[self.player][iname.double] = 1 + elif self.options.early_escape_item == EarlyEscapeItem.option_roc_wing: + self.multiworld.local_early_items[self.player][iname.roc_wing] = 1 + elif self.options.early_escape_item == EarlyEscapeItem.option_double_or_roc_wing: + self.multiworld.local_early_items[self.player][self.random.choice([iname.double, iname.roc_wing])] = 1 + + def create_regions(self) -> None: + # Create every Region object. + created_regions = [Region(name, self.player, self.multiworld) for name in get_all_region_names()] + + # Attach the Regions to the Multiworld. + self.multiworld.regions.extend(created_regions) + + for reg in created_regions: + + # Add the Entrances to all the Regions. + ent_destinations_and_names = get_region_info(reg.name, "entrances") + if ent_destinations_and_names is not None: + reg.add_exits(ent_destinations_and_names) + + # Add the Locations to all the Regions. + loc_names = get_region_info(reg.name, "locations") + if loc_names is None: + continue + locations_with_ids, locked_pairs = get_named_locations_data(loc_names, self.options) + reg.add_locations(locations_with_ids, CVCotMLocation) + + # Place locked Items on all of their associated Locations. + for locked_loc, locked_item in locked_pairs.items(): + self.get_location(locked_loc).place_locked_item(self.create_item(locked_item, + ItemClassification.progression)) + + def create_item(self, name: str, force_classification: typing.Optional[ItemClassification] = None) -> Item: + if force_classification is not None: + classification = force_classification + else: + classification = cvcotm_item_info[name].default_classification + + code = cvcotm_item_info[name].code + if code is not None: + code += BASE_ID + + created_item = CVCotMItem(name, classification, code, self.player) + + return created_item + + def create_items(self) -> None: + item_counts = get_item_counts(self) + + # Set up the items correctly + self.multiworld.itempool += [self.create_item(item, classification) for classification in item_counts for item + in item_counts[classification] for _ in range(item_counts[classification][item])] + + def set_rules(self) -> None: + # Set all the Entrance and Location rules properly. + CVCotMRules(self).set_cvcotm_rules() + + def generate_output(self, output_directory: str) -> None: + # Get out all the Locations that are not Events. Only take the Iron Maiden switch if the Maiden Detonator is in + # the item pool. + active_locations = [loc for loc in self.multiworld.get_locations(self.player) if loc.address is not None and + (loc.name != lname.ct21 or self.options.iron_maiden_behavior == + IronMaidenBehavior.option_detonator_in_pool)] + + # Location data + offset_data = get_location_data(self, active_locations) + # Sub-weapons + if self.options.sub_weapon_shuffle: + offset_data.update(shuffle_sub_weapons(self)) + # Item drop randomization + if self.options.item_drop_randomization: + offset_data.update(populate_enemy_drops(self)) + # Countdown + if self.options.countdown: + offset_data.update(get_countdown_flags(self, active_locations)) + # Start Inventory + start_inventory_data = get_start_inventory_data(self) + offset_data.update(start_inventory_data[0]) + + patch = CVCotMProcedurePatch(player=self.player, player_name=self.player_name) + patch_rom(self, patch, offset_data, start_inventory_data[1]) + + rom_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}" + f"{patch.patch_file_ending}") + + patch.write(rom_path) + + def fill_slot_data(self) -> dict: + return {"death_link": self.options.death_link.value, + "iron_maiden_behavior": self.options.iron_maiden_behavior.value, + "ignore_cleansing": self.options.ignore_cleansing.value, + "skip_tutorials": self.options.skip_tutorials.value, + "required_last_keys": self.required_last_keys, + "completion_goal": self.options.completion_goal.value} + + def get_filler_item_name(self) -> str: + return self.random.choice(FILLER_ITEM_NAMES) + + def modify_multidata(self, multidata: typing.Dict[str, typing.Any]): + # Put the player's unique authentication in connect_names. + multidata["connect_names"][base64.b64encode(self.auth).decode("ascii")] = \ + multidata["connect_names"][self.player_name] diff --git a/worlds/cvcotm/aesthetics.py b/worlds/cvcotm/aesthetics.py new file mode 100644 index 000000000000..d1668b1db18d --- /dev/null +++ b/worlds/cvcotm/aesthetics.py @@ -0,0 +1,761 @@ +from BaseClasses import ItemClassification, Location +from .options import ItemDropRandomization, Countdown, RequiredSkirmishes, IronMaidenBehavior +from .locations import cvcotm_location_info +from .items import cvcotm_item_info, MAJORS_CLASSIFICATIONS +from .data import iname + +from typing import TYPE_CHECKING, Dict, List, Iterable, Tuple, NamedTuple, Optional, TypedDict + +if TYPE_CHECKING: + from . import CVCotMWorld + + +class StatInfo(TypedDict): + # Amount this stat increases per Max Up the player starts with. + amount_per: int + # The most amount of this stat the player is allowed to start with. Problems arise if the stat exceeds 9999, so we + # must ensure it can't if the player raises any class to level 99 as well as collects 255 of that max up. The game + # caps hearts at 999 automatically, so it doesn't matter so much for that one. + max_allowed: int + # The key variable in extra_stats that the stat max up affects. + variable: str + + +extra_starting_stat_info: Dict[str, StatInfo] = { + iname.hp_max: {"amount_per": 10, + "max_allowed": 5289, + "variable": "extra health"}, + iname.mp_max: {"amount_per": 10, + "max_allowed": 3129, + "variable": "extra magic"}, + iname.heart_max: {"amount_per": 6, + "max_allowed": 999, + "variable": "extra hearts"}, +} + +other_player_subtype_bytes = { + 0xE4: 0x03, + 0xE6: 0x14, + 0xE8: 0x0A +} + + +class OtherGameAppearancesInfo(TypedDict): + # What type of item to place for the other player. + type: int + # What item to display it as for the other player. + appearance: int + + +other_game_item_appearances: Dict[str, Dict[str, OtherGameAppearancesInfo]] = { + # NOTE: Symphony of the Night is currently an unsupported world not in main. + "Symphony of the Night": {"Life Vessel": {"type": 0xE4, + "appearance": 0x01}, + "Heart Vessel": {"type": 0xE4, + "appearance": 0x00}}, + "Timespinner": {"Max HP": {"type": 0xE4, + "appearance": 0x01}, + "Max Aura": {"type": 0xE4, + "appearance": 0x02}, + "Max Sand": {"type": 0xE8, + "appearance": 0x0F}} +} + +# 0 = Holy water 22 +# 1 = Axe 24 +# 2 = Knife 32 +# 3 = Cross 6 +# 4 = Stopwatch 12 +# 5 = Small heart +# 6 = Big heart +rom_sub_weapon_offsets = { + 0xD034E: b"\x01", + 0xD0462: b"\x02", + 0xD064E: b"\x00", + 0xD06F6: b"\x02", + 0xD0882: b"\x00", + 0xD0912: b"\x02", + 0xD0C2A: b"\x02", + 0xD0C96: b"\x01", + 0xD0D92: b"\x02", + 0xD0DCE: b"\x01", + 0xD1332: b"\x00", + 0xD13AA: b"\x01", + 0xD1722: b"\x02", + 0xD17A6: b"\x01", + 0xD1926: b"\x01", + 0xD19AA: b"\x02", + 0xD1A9A: b"\x02", + 0xD1AA6: b"\x00", + 0xD1EBA: b"\x00", + 0xD1ED2: b"\x01", + 0xD2262: b"\x02", + 0xD23B2: b"\x03", + 0xD256E: b"\x02", + 0xD2742: b"\x02", + 0xD2832: b"\x04", + 0xD2862: b"\x01", + 0xD2A2A: b"\x01", + 0xD2DBA: b"\x04", + 0xD2DC6: b"\x00", + 0xD2E02: b"\x02", + 0xD2EFE: b"\x04", + 0xD2F0A: b"\x02", + 0xD302A: b"\x00", + 0xD3042: b"\x01", + 0xD304E: b"\x04", + 0xD3066: b"\x02", + 0xD322E: b"\x04", + 0xD334E: b"\x04", + 0xD3516: b"\x03", + 0xD35CA: b"\x02", + 0xD371A: b"\x01", + 0xD38EE: b"\x00", + 0xD3BE2: b"\x02", + 0xD3D1A: b"\x01", + 0xD3D56: b"\x02", + 0xD3ECA: b"\x00", + 0xD3EE2: b"\x02", + 0xD4056: b"\x01", + 0xD40E6: b"\x04", + 0xD413A: b"\x04", + 0xD4326: b"\x00", + 0xD460E: b"\x00", + 0xD48D2: b"\x00", + 0xD49E6: b"\x01", + 0xD4ABE: b"\x02", + 0xD4B8A: b"\x01", + 0xD4D0A: b"\x04", + 0xD4EAE: b"\x02", + 0xD4F0E: b"\x00", + 0xD4F92: b"\x02", + 0xD4FB6: b"\x01", + 0xD503A: b"\x03", + 0xD5646: b"\x01", + 0xD5682: b"\x02", + 0xD57C6: b"\x02", + 0xD57D2: b"\x02", + 0xD58F2: b"\x00", + 0xD5922: b"\x01", + 0xD5B9E: b"\x02", + 0xD5E26: b"\x01", + 0xD5E56: b"\x02", + 0xD5E7A: b"\x02", + 0xD5F5E: b"\x00", + 0xD69EA: b"\x02", + 0xD69F6: b"\x01", + 0xD6A02: b"\x00", + 0xD6A0E: b"\x04", + 0xD6A1A: b"\x03", + 0xD6BE2: b"\x00", + 0xD6CBA: b"\x01", + 0xD6CDE: b"\x02", + 0xD6EEE: b"\x00", + 0xD6F1E: b"\x02", + 0xD6F42: b"\x01", + 0xD6FC6: b"\x04", + 0xD706E: b"\x00", + 0xD716A: b"\x02", + 0xD72AE: b"\x01", + 0xD75BA: b"\x03", + 0xD76AA: b"\x04", + 0xD76B6: b"\x00", + 0xD76C2: b"\x01", + 0xD76CE: b"\x02", + 0xD76DA: b"\x03", + 0xD7D46: b"\x00", + 0xD7D52: b"\x00", +} + +LOW_ITEMS = [ + 41, # Potion + 42, # Meat + 48, # Mind Restore + 51, # Heart + 46, # Antidote + 47, # Cure Curse + + 17, # Cotton Clothes + 18, # Prison Garb + 12, # Cotton Robe + 1, # Leather Armor + 2, # Bronze Armor + 3, # Gold Armor + + 39, # Toy Ring + 40, # Bear Ring + 34, # Wristband + 36, # Arm Guard + 37, # Magic Gauntlet + 38, # Miracle Armband + 35, # Gauntlet +] + +MID_ITEMS = [ + 43, # Spiced Meat + 49, # Mind High + 52, # Heart High + + 19, # Stylish Suit + 20, # Night Suit + 13, # Silk Robe + 14, # Rainbow Robe + 4, # Chainmail + 5, # Steel Armor + 6, # Platinum Armor + + 24, # Star Bracelet + 29, # Cursed Ring + 25, # Strength Ring + 26, # Hard Ring + 27, # Intelligence Ring + 28, # Luck Ring + 23, # Double Grips +] + +HIGH_ITEMS = [ + 44, # Potion High + 45, # Potion Ex + 50, # Mind Ex + 53, # Heart Ex + 54, # Heart Mega + + 21, # Ninja Garb + 22, # Soldier Fatigues + 15, # Magic Robe + 16, # Sage Robe + + 7, # Diamond Armor + 8, # Mirror Armor + 9, # Needle Armor + 10, # Dark Armor + + 30, # Strength Armband + 31, # Defense Armband + 32, # Sage Armband + 33, # Gambler Armband +] + +COMMON_ITEMS = LOW_ITEMS + MID_ITEMS + +RARE_ITEMS = LOW_ITEMS + MID_ITEMS + HIGH_ITEMS + + +class CVCotMEnemyData(NamedTuple): + name: str + hp: int + attack: int + defense: int + exp: int + type: Optional[str] = None + + +cvcotm_enemy_info: List[CVCotMEnemyData] = [ + # Name HP ATK DEF EXP + CVCotMEnemyData("Medusa Head", 6, 120, 60, 2), + CVCotMEnemyData("Zombie", 48, 70, 20, 2), + CVCotMEnemyData("Ghoul", 100, 190, 79, 3), + CVCotMEnemyData("Wight", 110, 235, 87, 4), + CVCotMEnemyData("Clinking Man", 80, 135, 25, 21), + CVCotMEnemyData("Zombie Thief", 120, 185, 30, 58), + CVCotMEnemyData("Skeleton", 25, 65, 45, 4), + CVCotMEnemyData("Skeleton Bomber", 20, 50, 40, 4), + CVCotMEnemyData("Electric Skeleton", 42, 80, 50, 30), + CVCotMEnemyData("Skeleton Spear", 30, 65, 46, 6), + CVCotMEnemyData("Skeleton Boomerang", 60, 170, 90, 112), + CVCotMEnemyData("Skeleton Soldier", 35, 90, 60, 16), + CVCotMEnemyData("Skeleton Knight", 50, 140, 80, 39), + CVCotMEnemyData("Bone Tower", 84, 201, 280, 160), + CVCotMEnemyData("Fleaman", 60, 142, 45, 29), + CVCotMEnemyData("Poltergeist", 105, 360, 380, 510), + CVCotMEnemyData("Bat", 5, 50, 15, 4), + CVCotMEnemyData("Spirit", 9, 55, 17, 1), + CVCotMEnemyData("Ectoplasm", 12, 165, 51, 2), + CVCotMEnemyData("Specter", 15, 295, 95, 3), + CVCotMEnemyData("Axe Armor", 55, 120, 130, 31), + CVCotMEnemyData("Flame Armor", 160, 320, 300, 280), + CVCotMEnemyData("Flame Demon", 300, 315, 270, 600), + CVCotMEnemyData("Ice Armor", 240, 470, 520, 1500), + CVCotMEnemyData("Thunder Armor", 204, 340, 320, 800), + CVCotMEnemyData("Wind Armor", 320, 500, 460, 1800), + CVCotMEnemyData("Earth Armor", 130, 230, 280, 240), + CVCotMEnemyData("Poison Armor", 260, 382, 310, 822), + CVCotMEnemyData("Forest Armor", 370, 390, 390, 1280), + CVCotMEnemyData("Stone Armor", 90, 220, 320, 222), + CVCotMEnemyData("Ice Demon", 350, 492, 510, 4200), + CVCotMEnemyData("Holy Armor", 350, 420, 450, 1700), + CVCotMEnemyData("Thunder Demon", 180, 270, 230, 450), + CVCotMEnemyData("Dark Armor", 400, 680, 560, 3300), + CVCotMEnemyData("Wind Demon", 400, 540, 490, 3600), + CVCotMEnemyData("Bloody Sword", 30, 220, 500, 200), + CVCotMEnemyData("Golem", 650, 520, 700, 1400), + CVCotMEnemyData("Earth Demon", 150, 90, 85, 25), + CVCotMEnemyData("Were-wolf", 160, 265, 110, 140), + CVCotMEnemyData("Man Eater", 400, 330, 233, 700), + CVCotMEnemyData("Devil Tower", 10, 140, 200, 17), + CVCotMEnemyData("Skeleton Athlete", 100, 100, 50, 25), + CVCotMEnemyData("Harpy", 120, 275, 200, 271), + CVCotMEnemyData("Siren", 160, 443, 300, 880), + CVCotMEnemyData("Imp", 90, 220, 99, 103), + CVCotMEnemyData("Mudman", 25, 79, 30, 2), + CVCotMEnemyData("Gargoyle", 60, 160, 66, 3), + CVCotMEnemyData("Slime", 40, 102, 18, 11), + CVCotMEnemyData("Frozen Shade", 112, 490, 560, 1212), + CVCotMEnemyData("Heat Shade", 80, 240, 200, 136), + CVCotMEnemyData("Poison Worm", 120, 30, 20, 12), + CVCotMEnemyData("Myconid", 50, 250, 114, 25), + CVCotMEnemyData("Will O'Wisp", 11, 110, 16, 9), + CVCotMEnemyData("Spearfish", 40, 360, 450, 280), + CVCotMEnemyData("Merman", 60, 303, 301, 10), + CVCotMEnemyData("Minotaur", 410, 520, 640, 2000), + CVCotMEnemyData("Were-horse", 400, 540, 360, 1970), + CVCotMEnemyData("Marionette", 80, 160, 150, 127), + CVCotMEnemyData("Gremlin", 30, 80, 33, 2), + CVCotMEnemyData("Hopper", 40, 87, 35, 8), + CVCotMEnemyData("Evil Pillar", 20, 460, 800, 480), + CVCotMEnemyData("Were-panther", 200, 300, 130, 270), + CVCotMEnemyData("Were-jaguar", 270, 416, 170, 760), + CVCotMEnemyData("Bone Head", 24, 60, 80, 7), + CVCotMEnemyData("Fox Archer", 75, 130, 59, 53), + CVCotMEnemyData("Fox Hunter", 100, 290, 140, 272), + CVCotMEnemyData("Were-bear", 265, 250, 140, 227), + CVCotMEnemyData("Grizzly", 600, 380, 200, 960), + CVCotMEnemyData("Cerberus", 600, 150, 100, 500, "boss"), + CVCotMEnemyData("Beast Demon", 150, 330, 250, 260), + CVCotMEnemyData("Arch Demon", 320, 505, 400, 1000), + CVCotMEnemyData("Demon Lord", 460, 660, 500, 1950), + CVCotMEnemyData("Gorgon", 230, 215, 165, 219), + CVCotMEnemyData("Catoblepas", 550, 500, 430, 1800), + CVCotMEnemyData("Succubus", 150, 400, 350, 710), + CVCotMEnemyData("Fallen Angel", 370, 770, 770, 6000), + CVCotMEnemyData("Necromancer", 500, 200, 250, 2500, "boss"), + CVCotMEnemyData("Hyena", 93, 140, 70, 105), + CVCotMEnemyData("Fishhead", 80, 320, 504, 486), + CVCotMEnemyData("Dryad", 120, 300, 360, 300), + CVCotMEnemyData("Mimic Candle", 990, 600, 600, 6600, "candle"), + CVCotMEnemyData("Brain Float", 20, 50, 25, 10), + CVCotMEnemyData("Evil Hand", 52, 150, 120, 63), + CVCotMEnemyData("Abiondarg", 88, 388, 188, 388), + CVCotMEnemyData("Iron Golem", 640, 290, 450, 8000, "boss"), + CVCotMEnemyData("Devil", 1080, 800, 900, 10000), + CVCotMEnemyData("Witch", 144, 330, 290, 600), + CVCotMEnemyData("Mummy", 100, 100, 35, 3), + CVCotMEnemyData("Hipogriff", 300, 500, 210, 740), + CVCotMEnemyData("Adramelech", 1800, 380, 360, 16000, "boss"), + CVCotMEnemyData("Arachne", 330, 420, 288, 1300), + CVCotMEnemyData("Death Mantis", 200, 318, 240, 400), + CVCotMEnemyData("Alraune", 774, 490, 303, 2500), + CVCotMEnemyData("King Moth", 140, 290, 160, 150), + CVCotMEnemyData("Killer Bee", 8, 308, 108, 88), + CVCotMEnemyData("Dragon Zombie", 1400, 390, 440, 15000, "boss"), + CVCotMEnemyData("Lizardman", 100, 345, 400, 800), + CVCotMEnemyData("Franken", 1200, 700, 350, 2100), + CVCotMEnemyData("Legion", 420, 610, 375, 1590), + CVCotMEnemyData("Dullahan", 240, 550, 440, 2200), + CVCotMEnemyData("Death", 880, 600, 800, 60000, "boss"), + CVCotMEnemyData("Camilla", 1500, 650, 700, 80000, "boss"), + CVCotMEnemyData("Hugh", 1400, 570, 750, 120000, "boss"), + CVCotMEnemyData("Dracula", 1100, 805, 850, 150000, "boss"), + CVCotMEnemyData("Dracula", 3000, 1000, 1000, 0, "final boss"), + CVCotMEnemyData("Skeleton Medalist", 250, 100, 100, 1500), + CVCotMEnemyData("Were-jaguar", 320, 518, 260, 1200, "battle arena"), + CVCotMEnemyData("Were-wolf", 340, 525, 180, 1100, "battle arena"), + CVCotMEnemyData("Catoblepas", 560, 510, 435, 2000, "battle arena"), + CVCotMEnemyData("Hipogriff", 500, 620, 280, 1900, "battle arena"), + CVCotMEnemyData("Wind Demon", 490, 600, 540, 4000, "battle arena"), + CVCotMEnemyData("Witch", 210, 480, 340, 1000, "battle arena"), + CVCotMEnemyData("Stone Armor", 260, 585, 750, 3000, "battle arena"), + CVCotMEnemyData("Devil Tower", 50, 560, 700, 600, "battle arena"), + CVCotMEnemyData("Skeleton", 150, 400, 200, 500, "battle arena"), + CVCotMEnemyData("Skeleton Bomber", 150, 400, 200, 550, "battle arena"), + CVCotMEnemyData("Electric Skeleton", 150, 400, 200, 700, "battle arena"), + CVCotMEnemyData("Skeleton Spear", 150, 400, 200, 580, "battle arena"), + CVCotMEnemyData("Flame Demon", 680, 650, 600, 4500, "battle arena"), + CVCotMEnemyData("Bone Tower", 120, 500, 650, 800, "battle arena"), + CVCotMEnemyData("Fox Hunter", 160, 510, 220, 600, "battle arena"), + CVCotMEnemyData("Poison Armor", 380, 680, 634, 3600, "battle arena"), + CVCotMEnemyData("Bloody Sword", 55, 600, 1200, 2000, "battle arena"), + CVCotMEnemyData("Abiondarg", 188, 588, 288, 588, "battle arena"), + CVCotMEnemyData("Legion", 540, 760, 480, 2900, "battle arena"), + CVCotMEnemyData("Marionette", 200, 420, 400, 1200, "battle arena"), + CVCotMEnemyData("Minotaur", 580, 700, 715, 4100, "battle arena"), + CVCotMEnemyData("Arachne", 430, 590, 348, 2400, "battle arena"), + CVCotMEnemyData("Succubus", 300, 670, 630, 3100, "battle arena"), + CVCotMEnemyData("Demon Lord", 590, 800, 656, 4200, "battle arena"), + CVCotMEnemyData("Alraune", 1003, 640, 450, 5000, "battle arena"), + CVCotMEnemyData("Hyena", 210, 408, 170, 1000, "battle arena"), + CVCotMEnemyData("Devil Armor", 500, 804, 714, 6600), + CVCotMEnemyData("Evil Pillar", 55, 655, 900, 1500, "battle arena"), + CVCotMEnemyData("White Armor", 640, 770, 807, 7000), + CVCotMEnemyData("Devil", 1530, 980, 1060, 30000, "battle arena"), + CVCotMEnemyData("Scary Candle", 150, 300, 300, 900, "candle"), + CVCotMEnemyData("Trick Candle", 200, 400, 400, 1400, "candle"), + CVCotMEnemyData("Nightmare", 250, 550, 550, 2000), + CVCotMEnemyData("Lilim", 400, 800, 800, 8000), + CVCotMEnemyData("Lilith", 660, 960, 960, 20000), +] +# NOTE: Coffin is omitted from the end of this, as its presence doesn't +# actually impact the randomizer (all stats and drops inherited from Mummy). + +BOSS_IDS = [enemy_id for enemy_id in range(len(cvcotm_enemy_info)) if cvcotm_enemy_info[enemy_id].type == "boss"] + +ENEMY_TABLE_START = 0xCB2C4 + +NUMBER_ITEMS = 55 + +COUNTDOWN_TABLE_ADDR = 0x673400 +ITEM_ID_SHINNING_ARMOR = 11 + + +def shuffle_sub_weapons(world: "CVCotMWorld") -> Dict[int, bytes]: + """Shuffles the sub-weapons amongst themselves.""" + sub_bytes = list(rom_sub_weapon_offsets.values()) + world.random.shuffle(sub_bytes) + return dict(zip(rom_sub_weapon_offsets, sub_bytes)) + + +def get_countdown_flags(world: "CVCotMWorld", active_locations: Iterable[Location]) -> Dict[int, bytes]: + """Figures out which Countdown numbers to increase for each Location after verifying the Item on the Location should + count towards a number. + + Which number to increase is determined by the Location's "countdown" attr in its CVCotMLocationData.""" + + next_pos = COUNTDOWN_TABLE_ADDR + 0x40 + countdown_flags: List[List[int]] = [[] for _ in range(16)] + countdown_dict = {} + ptr_offset = COUNTDOWN_TABLE_ADDR + + # Loop over every Location. + for loc in active_locations: + # If the Location's Item is not Progression/Useful-classified with the "Majors" Countdown being used, or if the + # Location is the Iron Maiden switch with the vanilla Iron Maiden behavior, skip adding its flag to the arrays. + if (not loc.item.classification & MAJORS_CLASSIFICATIONS and world.options.countdown == + Countdown.option_majors): + continue + + countdown_index = cvcotm_location_info[loc.name].countdown + # Take the Location's address if the above condition is satisfied, and get the flag value out of it. + countdown_flags[countdown_index] += [loc.address & 0xFF, 0] + + # Write the Countdown flag arrays and array pointers correctly. Each flag list should end with a 0xFFFF to indicate + # the end of an area's list. + for area_flags in countdown_flags: + countdown_dict[ptr_offset] = int.to_bytes(next_pos | 0x08000000, 4, "little") + countdown_dict[next_pos] = bytes(area_flags + [0xFF, 0xFF]) + ptr_offset += 4 + next_pos += len(area_flags) + 2 + + return countdown_dict + + +def get_location_data(world: "CVCotMWorld", active_locations: Iterable[Location]) -> Dict[int, bytes]: + """Gets ALL the Item data to go into the ROM. Items consist of four bytes; the first two represent the object ID + for the "category" of item that it belongs to, the third is the sub-value for which item within that "category" it + is, and the fourth controls the appearance it takes.""" + + location_bytes = {} + + for loc in active_locations: + # Figure out the item ID bytes to put in each Location's offset here. + # If it's a CotM Item, always write the Item's primary type byte. + if loc.item.game == "Castlevania - Circle of the Moon": + type_byte = cvcotm_item_info[loc.item.name].code >> 8 + + # If the Item is for this player, set the subtype to actually be that Item. + # Otherwise, set a dummy subtype value that is different for every item type. + if loc.item.player == world.player: + subtype_byte = cvcotm_item_info[loc.item.name].code & 0xFF + else: + subtype_byte = other_player_subtype_bytes[type_byte] + + # If it's a DSS Card, set the appearance based on whether it's progression or not; freeze combo cards should + # all appear blue in color while the others are standard purple/yellow. Otherwise, set the appearance the + # same way as the subtype for local items regardless of whether it's actually local or not. + if type_byte == 0xE6: + if loc.item.advancement: + appearance_byte = 1 + else: + appearance_byte = 0 + else: + appearance_byte = cvcotm_item_info[loc.item.name].code & 0xFF + + # If it's not a CotM Item at all, always set the primary type to that of a Magic Item and the subtype to that of + # a dummy item. The AP Items are all under Magic Items. + else: + type_byte = 0xE8 + subtype_byte = 0x0A + # Decide which AP Item to use to represent the other game item. + if loc.item.classification & ItemClassification.progression and \ + loc.item.classification & ItemClassification.useful: + appearance_byte = 0x0E # Progression + Useful + elif loc.item.classification & ItemClassification.progression: + appearance_byte = 0x0C # Progression + elif loc.item.classification & ItemClassification.useful: + appearance_byte = 0x0B # Useful + elif loc.item.classification & ItemClassification.trap: + appearance_byte = 0x0D # Trap + else: + appearance_byte = 0x0A # Filler + + # Check if the Item's game is in the other game item appearances' dict, and if so, if the Item is under that + # game's name. If it is, change the appearance accordingly. + # Right now, only SotN and Timespinner stat ups are supported. + other_game_name = world.multiworld.worlds[loc.item.player].game + if other_game_name in other_game_item_appearances: + if loc.item.name in other_game_item_appearances[other_game_name]: + type_byte = other_game_item_appearances[other_game_name][loc.item.name]["type"] + subtype_byte = other_player_subtype_bytes[type_byte] + appearance_byte = other_game_item_appearances[other_game_name][loc.item.name]["appearance"] + + # Create the correct bytes object for the Item on that Location. + location_bytes[cvcotm_location_info[loc.name].offset] = bytes([type_byte, 1, subtype_byte, appearance_byte]) + return location_bytes + + +def populate_enemy_drops(world: "CVCotMWorld") -> Dict[int, bytes]: + """Randomizes the enemy-dropped items throughout the game within each other. There are three tiers of item drops: + Low, Mid, and High. Each enemy has two item slots that can both drop its own item; a Common slot and a Rare one. + + On Normal item randomization, easy enemies (below 61 HP) will only have Low-tier drops in both of their stats, + bosses and candle enemies will be guaranteed to have High drops in one or both of their slots respectively (bosses + are made to only drop one slot 100% of the time), and everything else can have a Low or Mid-tier item in its Common + drop slot and a Low, Mid, OR High-tier item in its Rare drop slot. + + If Item Drop Randomization is set to Tiered, the HP threshold for enemies being considered "easily" will raise to + below 144, enemies in the 144-369 HP range (inclusive) will have a Low-tier item in its Common slot and a Mid-tier + item in its rare slot, and enemies with more than 369 HP will have a Mid-tier in its Common slot and a High-tier in + its Rare slot. Candles and bosses still have Rares in all their slots, but now the guaranteed drops that land on + bosses will be exclusive to them; no other enemy in the game will have their item. + + This and select_drop are the most directly adapted code from upstream CotMR in this package by far. Credit where + it's due to Spooky for writing the original, and Malaert64 for further refinements and updating what used to be + Random Item Hardmode to instead be Tiered Item Mode. The original C code this was adapted from can be found here: + https://github.com/calm-palm/cotm-randomizer/blob/master/Program/randomizer.c#L1028""" + + placed_low_items = [0] * len(LOW_ITEMS) + placed_mid_items = [0] * len(MID_ITEMS) + placed_high_items = [0] * len(HIGH_ITEMS) + + placed_common_items = [0] * len(COMMON_ITEMS) + placed_rare_items = [0] * len(RARE_ITEMS) + + regular_drops = [0] * len(cvcotm_enemy_info) + regular_drop_chances = [0] * len(cvcotm_enemy_info) + rare_drops = [0] * len(cvcotm_enemy_info) + rare_drop_chances = [0] * len(cvcotm_enemy_info) + + # Set boss items first to prevent boss drop duplicates. + # If Tiered mode is enabled, make these items exclusive to these enemies by adding an arbitrary integer larger + # than could be reached normally (e.g.the total number of enemies) and use the placed high items array instead of + # the placed rare items one. + if world.options.item_drop_randomization == ItemDropRandomization.option_tiered: + for boss_id in BOSS_IDS: + regular_drops[boss_id] = select_drop(world, HIGH_ITEMS, placed_high_items, True) + else: + for boss_id in BOSS_IDS: + regular_drops[boss_id] = select_drop(world, RARE_ITEMS, placed_rare_items, start_index=len(COMMON_ITEMS)) + + # Setting drop logic for all enemies. + for i in range(len(cvcotm_enemy_info)): + + # Give Dracula II Shining Armor occasionally as a joke. + if cvcotm_enemy_info[i].type == "final boss": + regular_drops[i] = rare_drops[i] = ITEM_ID_SHINNING_ARMOR + regular_drop_chances[i] = rare_drop_chances[i] = 5000 + + # Set bosses' secondary item to none since we already set the primary item earlier. + elif cvcotm_enemy_info[i].type == "boss": + # Set rare drop to none. + rare_drops[i] = 0 + + # Max out rare boss drops (normally, drops are capped to 50% and 25% for common and rare respectively, but + # Fuse's patch AllowAlwaysDrop.ips allows setting the regular item drop chance to 10000 to force a drop + # always) + regular_drop_chances[i] = 10000 + rare_drop_chances[i] = 0 + + # Candle enemies use a similar placement logic to the bosses, except items that land on them are NOT exclusive + # to them on Tiered mode. + elif cvcotm_enemy_info[i].type == "candle": + if world.options.item_drop_randomization == ItemDropRandomization.option_tiered: + regular_drops[i] = select_drop(world, HIGH_ITEMS, placed_high_items) + rare_drops[i] = select_drop(world, HIGH_ITEMS, placed_high_items) + else: + regular_drops[i] = select_drop(world, RARE_ITEMS, placed_rare_items, start_index=len(COMMON_ITEMS)) + rare_drops[i] = select_drop(world, RARE_ITEMS, placed_rare_items, start_index=len(COMMON_ITEMS)) + + # Set base drop chances at 20-30% for common and 15-20% for rare. + regular_drop_chances[i] = 2000 + world.random.randint(0, 1000) + rare_drop_chances[i] = 1500 + world.random.randint(0, 500) + + # On All Bosses and Battle Arena Required, the Shinning Armor at the end of Battle Arena is removed. + # We compensate for this by giving the Battle Arena Devil a 100% chance to drop Shinning Armor. + elif cvcotm_enemy_info[i].name == "Devil" and cvcotm_enemy_info[i].type == "battle arena" and \ + world.options.required_skirmishes == RequiredSkirmishes.option_all_bosses_and_arena: + regular_drops[i] = ITEM_ID_SHINNING_ARMOR + rare_drops[i] = 0 + + regular_drop_chances[i] = 10000 + rare_drop_chances[i] = 0 + + # Low-tier items drop from enemies that are trivial to farm (60 HP or less) + # on Normal drop logic, or enemies under 144 HP on Tiered logic. + elif (world.options.item_drop_randomization == ItemDropRandomization.option_normal and + cvcotm_enemy_info[i].hp <= 60) or \ + (world.options.item_drop_randomization == ItemDropRandomization.option_tiered and + cvcotm_enemy_info[i].hp <= 143): + # Low-tier enemy drops. + regular_drops[i] = select_drop(world, LOW_ITEMS, placed_low_items) + rare_drops[i] = select_drop(world, LOW_ITEMS, placed_low_items) + + # Set base drop chances at 6-10% for common and 3-6% for rare. + regular_drop_chances[i] = 600 + world.random.randint(0, 400) + rare_drop_chances[i] = 300 + world.random.randint(0, 300) + + # Rest of Tiered logic, by Malaert64. + elif world.options.item_drop_randomization == ItemDropRandomization.option_tiered: + # If under 370 HP, mid-tier enemy. + if cvcotm_enemy_info[i].hp <= 369: + regular_drops[i] = select_drop(world, LOW_ITEMS, placed_low_items) + rare_drops[i] = select_drop(world, MID_ITEMS, placed_mid_items) + # Otherwise, enemy HP is 370+, thus high-tier enemy. + else: + regular_drops[i] = select_drop(world, MID_ITEMS, placed_mid_items) + rare_drops[i] = select_drop(world, HIGH_ITEMS, placed_high_items) + + # Set base drop chances at 6-10% for common and 3-6% for rare. + regular_drop_chances[i] = 600 + world.random.randint(0, 400) + rare_drop_chances[i] = 300 + world.random.randint(0, 300) + + # Regular enemies outside Tiered logic. + else: + # Select a random regular and rare drop for every enemy from their respective lists. + regular_drops[i] = select_drop(world, COMMON_ITEMS, placed_common_items) + rare_drops[i] = select_drop(world, RARE_ITEMS, placed_rare_items) + + # Set base drop chances at 6-10% for common and 3-6% for rare. + regular_drop_chances[i] = 600 + world.random.randint(0, 400) + rare_drop_chances[i] = 300 + world.random.randint(0, 300) + + # Return the randomized drop data as bytes with their respective offsets. + enemy_address = ENEMY_TABLE_START + drop_data = {} + for i, enemy_info in enumerate(cvcotm_enemy_info): + drop_data[enemy_address] = bytes([regular_drops[i], 0, regular_drop_chances[i] & 0xFF, + regular_drop_chances[i] >> 8, rare_drops[i], 0, rare_drop_chances[i] & 0xFF, + rare_drop_chances[i] >> 8]) + enemy_address += 20 + + return drop_data + + +def select_drop(world: "CVCotMWorld", drop_list: List[int], drops_placed: List[int], exclusive_drop: bool = False, + start_index: int = 0) -> int: + """Chooses a drop from a given list of drops based on another given list of how many drops from that list were + selected before. In order to ensure an even number of drops are distributed, drops that were selected the least are + the ones that will be picked from. + + Calling this with exclusive_drop param being True will force the number of the chosen item really high to ensure it + will never be picked again.""" + + # Take the list of placed item drops beginning from the starting index. + drops_from_start_index = drops_placed[start_index:] + + # Determine the lowest drop counts and the indices with that drop count. + lowest_number = min(drops_from_start_index) + indices_with_lowest_number = [index for index, placed in enumerate(drops_from_start_index) if + placed == lowest_number] + + random_index = world.random.choice(indices_with_lowest_number) + random_index += start_index # Add start_index back on + + # Increment the number of this item placed, unless it should be exclusive to the boss / candle, in which case + # set it to an arbitrarily large number to make it exclusive. + if exclusive_drop: + drops_placed[random_index] += 999 + else: + drops_placed[random_index] += 1 + + # Return the in-game item ID of the chosen item. + return drop_list[random_index] + + +def get_start_inventory_data(world: "CVCotMWorld") -> Tuple[Dict[int, bytes], bool]: + """Calculate and return the starting inventory arrays. Different items go into different arrays, so they all have + to be handled accordingly.""" + start_inventory_data = {} + + magic_items_array = [0 for _ in range(8)] + cards_array = [0 for _ in range(20)] + extra_stats = {"extra health": 0, + "extra magic": 0, + "extra hearts": 0} + start_with_detonator = False + # If the Iron Maiden Behavior option is set to Start Broken, consider ourselves starting with the Maiden Detonator. + if world.options.iron_maiden_behavior == IronMaidenBehavior.option_start_broken: + start_with_detonator = True + + # Always start with the Dash Boots. + magic_items_array[0] = 1 + + for item in world.multiworld.precollected_items[world.player]: + + array_offset = item.code & 0xFF + + # If it's a Maiden Detonator we're starting with, set the boolean for it to True. + if item.name == iname.ironmaidens: + start_with_detonator = True + # If it's a Max Up we're starting with, check if increasing the extra amount of that stat will put us over the + # max amount of the stat allowed. If it will, set the current extra amount to the max. Otherwise, increase it by + # the amount that it should. + elif "Max Up" in item.name: + info = extra_starting_stat_info[item.name] + if extra_stats[info["variable"]] + info["amount_per"] > info["max_allowed"]: + extra_stats[info["variable"]] = info["max_allowed"] + else: + extra_stats[info["variable"]] += info["amount_per"] + # If it's a DSS card we're starting with, set that card's value in the cards array. + elif "Card" in item.name: + cards_array[array_offset] = 1 + # If it's none of the above, it has to be a regular Magic Item. + # Increase that Magic Item's value in the Magic Items array if it's not greater than 240. Last Keys are the only + # Magic Item wherein having more than one is relevant. + else: + # Decrease the Magic Item array offset by 1 if it's higher than the unused Map's item value. + if array_offset > 5: + array_offset -= 1 + if magic_items_array[array_offset] < 240: + magic_items_array[array_offset] += 1 + + # Add the start inventory arrays to the offset data in bytes form. + start_inventory_data[0x680080] = bytes(magic_items_array) + start_inventory_data[0x6800A0] = bytes(cards_array) + + # Add the extra max HP/MP/Hearts to all classes' base stats. Doing it this way makes us less likely to hit the max + # possible Max Ups. + # Vampire Killer + start_inventory_data[0xE08C6] = int.to_bytes(100 + extra_stats["extra health"], 2, "little") + start_inventory_data[0xE08CE] = int.to_bytes(100 + extra_stats["extra magic"], 2, "little") + start_inventory_data[0xE08D4] = int.to_bytes(50 + extra_stats["extra hearts"], 2, "little") + + # Magician + start_inventory_data[0xE090E] = int.to_bytes(50 + extra_stats["extra health"], 2, "little") + start_inventory_data[0xE0916] = int.to_bytes(400 + extra_stats["extra magic"], 2, "little") + start_inventory_data[0xE091C] = int.to_bytes(50 + extra_stats["extra hearts"], 2, "little") + + # Fighter + start_inventory_data[0xE0932] = int.to_bytes(200 + extra_stats["extra health"], 2, "little") + start_inventory_data[0xE093A] = int.to_bytes(50 + extra_stats["extra magic"], 2, "little") + start_inventory_data[0xE0940] = int.to_bytes(50 + extra_stats["extra hearts"], 2, "little") + + # Shooter + start_inventory_data[0xE0832] = int.to_bytes(50 + extra_stats["extra health"], 2, "little") + start_inventory_data[0xE08F2] = int.to_bytes(100 + extra_stats["extra magic"], 2, "little") + start_inventory_data[0xE08F8] = int.to_bytes(250 + extra_stats["extra hearts"], 2, "little") + + # Thief + start_inventory_data[0xE0956] = int.to_bytes(50 + extra_stats["extra health"], 2, "little") + start_inventory_data[0xE095E] = int.to_bytes(50 + extra_stats["extra magic"], 2, "little") + start_inventory_data[0xE0964] = int.to_bytes(50 + extra_stats["extra hearts"], 2, "little") + + return start_inventory_data, start_with_detonator diff --git a/worlds/cvcotm/client.py b/worlds/cvcotm/client.py new file mode 100644 index 000000000000..4db2c2faabfa --- /dev/null +++ b/worlds/cvcotm/client.py @@ -0,0 +1,563 @@ +from typing import TYPE_CHECKING, Set +from .locations import BASE_ID, get_location_names_to_ids +from .items import cvcotm_item_info, MAJORS_CLASSIFICATIONS +from .locations import cvcotm_location_info +from .cvcotm_text import cvcotm_string_to_bytearray +from .options import CompletionGoal, CVCotMDeathLink, IronMaidenBehavior +from .rom import ARCHIPELAGO_IDENTIFIER_START, ARCHIPELAGO_IDENTIFIER, AUTH_NUMBER_START, QUEUED_TEXT_STRING_START +from .data import iname, lname + +from BaseClasses import ItemClassification +from NetUtils import ClientStatus +import worlds._bizhawk as bizhawk +import base64 +from worlds._bizhawk.client import BizHawkClient + +if TYPE_CHECKING: + from worlds._bizhawk.context import BizHawkClientContext + +CURRENT_STATUS_ADDRESS = 0xD0 +POISON_TIMER_TILL_DAMAGE_ADDRESS = 0xD8 +POISON_DAMAGE_VALUE_ADDRESS = 0xDE +GAME_STATE_ADDRESS = 0x45D8 +FLAGS_ARRAY_START = 0x25374 +CARDS_ARRAY_START = 0x25674 +NUM_RECEIVED_ITEMS_ADDRESS = 0x253D0 +MAX_UPS_ARRAY_START = 0x2572C +MAGIC_ITEMS_ARRAY_START = 0x2572F +QUEUED_TEXTBOX_1_ADDRESS = 0x25300 +QUEUED_TEXTBOX_2_ADDRESS = 0x25302 +QUEUED_MSG_DELAY_TIMER_ADDRESS = 0x25304 +QUEUED_SOUND_ID_ADDRESS = 0x25306 +DELAY_TIMER_ADDRESS = 0x25308 +CURRENT_CUTSCENE_ID_ADDRESS = 0x26000 +NATHAN_STATE_ADDRESS = 0x50 +CURRENT_HP_ADDRESS = 0x2562E +CURRENT_MP_ADDRESS = 0x25636 +CURRENT_HEARTS_ADDRESS = 0x2563C +CURRENT_LOCATION_VALUES_START = 0x253FC +ROM_NAME_START = 0xA0 + +AREA_SEALED_ROOM = 0x00 +AREA_BATTLE_ARENA = 0x0E +GAME_STATE_GAMEPLAY = 0x06 +GAME_STATE_CREDITS = 0x21 +NATHAN_STATE_SAVING = 0x34 +STATUS_POISON = b"\x02" +TEXT_ID_DSS_TUTORIAL = b"\x1D\x82" +TEXT_ID_MULTIWORLD_MESSAGE = b"\xF2\x84" +SOUND_ID_UNUSED_SIMON_FANFARE = b"\x04" +SOUND_ID_MAIDEN_BREAKING = b"\x79" +# SOUND_ID_NATHAN_FREEZING = b"\x7A" +SOUND_ID_BAD_CONFIG = b"\x2D\x01" +SOUND_ID_DRACULA_CHARGE = b"\xAB\x01" +SOUND_ID_MINOR_PICKUP = b"\xB3\x01" +SOUND_ID_MAJOR_PICKUP = b"\xB4\x01" + +ITEM_NAME_LIMIT = 300 +PLAYER_NAME_LIMIT = 50 + +FLAG_HIT_IRON_MAIDEN_SWITCH = 0x2A +FLAG_SAW_DSS_TUTORIAL = 0xB1 +FLAG_WON_BATTLE_ARENA = 0xB2 +FLAG_DEFEATED_DRACULA_II = 0xBC + +# These flags are communicated to the tracker as a bitfield using this order. +# Modifying the order will cause undetectable autotracking issues. +EVENT_FLAG_MAP = { + FLAG_HIT_IRON_MAIDEN_SWITCH: "FLAG_HIT_IRON_MAIDEN_SWITCH", + FLAG_WON_BATTLE_ARENA: "FLAG_WON_BATTLE_ARENA", + 0xB3: "FLAG_DEFEATED_CERBERUS", + 0xB4: "FLAG_DEFEATED_NECROMANCER", + 0xB5: "FLAG_DEFEATED_IRON_GOLEM", + 0xB6: "FLAG_DEFEATED_ADRAMELECH", + 0xB7: "FLAG_DEFEATED_DRAGON_ZOMBIES", + 0xB8: "FLAG_DEFEATED_DEATH", + 0xB9: "FLAG_DEFEATED_CAMILLA", + 0xBA: "FLAG_DEFEATED_HUGH", + 0xBB: "FLAG_DEFEATED_DRACULA_I", + FLAG_DEFEATED_DRACULA_II: "FLAG_DEFEATED_DRACULA_II" +} + +DEATHLINK_AREA_NAMES = ["Sealed Room", "Catacomb", "Abyss Staircase", "Audience Room", "Triumph Hallway", + "Machine Tower", "Eternal Corridor", "Chapel Tower", "Underground Warehouse", + "Underground Gallery", "Underground Waterway", "Outer Wall", "Observation Tower", + "Ceremonial Room", "Battle Arena"] + + +class CastlevaniaCotMClient(BizHawkClient): + game = "Castlevania - Circle of the Moon" + system = "GBA" + patch_suffix = ".apcvcotm" + sent_initial_packets: bool + self_induced_death: bool + local_checked_locations: Set[int] + client_set_events = {flag_name: False for flag, flag_name in EVENT_FLAG_MAP.items()} + killed_dracula_2: bool + won_battle_arena: bool + sent_message_queue: list + death_causes: list + currently_dead: bool + synced_set_events: bool + saw_arena_win_message: bool + saw_dss_tutorial: bool + + async def validate_rom(self, ctx: "BizHawkClientContext") -> bool: + from CommonClient import logger + + try: + # Check ROM name/patch version + game_names = await bizhawk.read(ctx.bizhawk_ctx, [(ROM_NAME_START, 0xC, "ROM"), + (ARCHIPELAGO_IDENTIFIER_START, 12, "ROM")]) + if game_names[0].decode("ascii") != "DRACULA AGB1": + return False + if game_names[1] == b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00': + logger.info("ERROR: You appear to be running an unpatched version of Castlevania: Circle of the Moon. " + "You need to generate a patch file and use it to create a patched ROM.") + return False + if game_names[1].decode("ascii") != ARCHIPELAGO_IDENTIFIER: + logger.info("ERROR: The patch file used to create this ROM is not compatible with " + "this client. Double check your client version against the version being " + "used by the generator.") + return False + except UnicodeDecodeError: + return False + except bizhawk.RequestFailedError: + return False # Should verify on the next pass + + ctx.game = self.game + ctx.items_handling = 0b001 + ctx.want_slot_data = True + ctx.watcher_timeout = 0.125 + return True + + async def set_auth(self, ctx: "BizHawkClientContext") -> None: + auth_raw = (await bizhawk.read(ctx.bizhawk_ctx, [(AUTH_NUMBER_START, 16, "ROM")]))[0] + ctx.auth = base64.b64encode(auth_raw).decode("utf-8") + # Initialize all the local client attributes here so that nothing will be carried over from a previous CotM if + # the player tried changing CotM ROMs without resetting their Bizhawk Client instance. + self.sent_initial_packets = False + self.local_checked_locations = set() + self.self_induced_death = False + self.client_set_events = {flag_name: False for flag, flag_name in EVENT_FLAG_MAP.items()} + self.killed_dracula_2 = False + self.won_battle_arena = False + self.sent_message_queue = [] + self.death_causes = [] + self.currently_dead = False + self.synced_set_events = False + self.saw_arena_win_message = False + self.saw_dss_tutorial = False + + def on_package(self, ctx: "BizHawkClientContext", cmd: str, args: dict) -> None: + if cmd != "Bounced": + return + if "tags" not in args: + return + if ctx.slot is None: + return + if "DeathLink" in args["tags"] and args["data"]["source"] != ctx.slot_info[ctx.slot].name: + if "cause" in args["data"]: + cause = args["data"]["cause"] + if cause == "": + cause = f"{args['data']['source']} killed you without a word!" + if len(cause) > ITEM_NAME_LIMIT + PLAYER_NAME_LIMIT: + cause = cause[:ITEM_NAME_LIMIT + PLAYER_NAME_LIMIT] + else: + cause = f"{args['data']['source']} killed you without a word!" + + # Highlight the player that killed us in the game's orange text. + if args['data']['source'] in cause: + words = cause.split(args['data']['source'], 1) + cause = words[0] + "「" + args['data']['source'] + "」" + words[1] + + self.death_causes += [cause] + + async def game_watcher(self, ctx: "BizHawkClientContext") -> None: + if ctx.server is None or ctx.server.socket.closed or ctx.slot_data is None or ctx.slot is None: + return + + try: + # Scout all Locations and get our Set events upon initial connection. + if not self.sent_initial_packets: + await ctx.send_msgs([{ + "cmd": "LocationScouts", + "locations": [code for name, code in get_location_names_to_ids().items() + if code in ctx.server_locations], + "create_as_hint": 0 + }]) + await ctx.send_msgs([{ + "cmd": "Get", + "keys": [f"castlevania_cotm_events_{ctx.team}_{ctx.slot}"] + }]) + self.sent_initial_packets = True + + read_state = await bizhawk.read(ctx.bizhawk_ctx, [(GAME_STATE_ADDRESS, 1, "EWRAM"), + (FLAGS_ARRAY_START, 32, "EWRAM"), + (CARDS_ARRAY_START, 20, "EWRAM"), + (NUM_RECEIVED_ITEMS_ADDRESS, 2, "EWRAM"), + (MAX_UPS_ARRAY_START, 3, "EWRAM"), + (MAGIC_ITEMS_ARRAY_START, 8, "EWRAM"), + (QUEUED_TEXTBOX_1_ADDRESS, 2, "EWRAM"), + (DELAY_TIMER_ADDRESS, 2, "EWRAM"), + (CURRENT_CUTSCENE_ID_ADDRESS, 1, "EWRAM"), + (NATHAN_STATE_ADDRESS, 1, "EWRAM"), + (CURRENT_HP_ADDRESS, 18, "EWRAM"), + (CURRENT_LOCATION_VALUES_START, 2, "EWRAM")]) + + game_state = int.from_bytes(read_state[0], "little") + event_flags_array = read_state[1] + cards_array = list(read_state[2]) + max_ups_array = list(read_state[4]) + magic_items_array = list(read_state[5]) + num_received_items = int.from_bytes(bytearray(read_state[3]), "little") + queued_textbox = int.from_bytes(bytearray(read_state[6]), "little") + delay_timer = int.from_bytes(bytearray(read_state[7]), "little") + cutscene = int.from_bytes(bytearray(read_state[8]), "little") + nathan_state = int.from_bytes(bytearray(read_state[9]), "little") + health_stats_array = bytearray(read_state[10]) + area = int.from_bytes(bytearray(read_state[11][0:1]), "little") + room = int.from_bytes(bytearray(read_state[11][1:]), "little") + + # Get out each of the individual health/magic/heart values. + hp = int.from_bytes(health_stats_array[0:2], "little") + max_hp = int.from_bytes(health_stats_array[4:6], "little") + # mp = int.from_bytes(health_stats_array[8:10], "little") Not used. But it's here if it's ever needed! + max_mp = int.from_bytes(health_stats_array[12:14], "little") + hearts = int.from_bytes(health_stats_array[14:16], "little") + max_hearts = int.from_bytes(health_stats_array[16:], "little") + + # If there's no textbox already queued, the delay timer is 0, we are not in a cutscene, and Nathan's current + # state value is not 0x34 (using a save room), it should be safe to inject a textbox message. + ok_to_inject = not queued_textbox and not delay_timer and not cutscene \ + and nathan_state != NATHAN_STATE_SAVING + + # Make sure we are in the Gameplay or Credits states before detecting sent locations. + # If we are in any other state, such as the Game Over state, reset the textbox buffers back to 0 so that we + # don't receive the most recent item upon loading back in. + # + # If the intro cutscene floor broken flag is not set, then assume we are in the demo; at no point during + # regular gameplay will this flag not be set. + if game_state not in [GAME_STATE_GAMEPLAY, GAME_STATE_CREDITS] or not event_flags_array[6] & 0x02: + self.currently_dead = False + await bizhawk.write(ctx.bizhawk_ctx, [(QUEUED_TEXTBOX_1_ADDRESS, [0 for _ in range(12)], "EWRAM")]) + return + + # Enable DeathLink if it's in our slot_data. + if "DeathLink" not in ctx.tags and ctx.slot_data["death_link"]: + await ctx.update_death_link(True) + + # Send a DeathLink if we died on our own independently of receiving another one. + if "DeathLink" in ctx.tags and hp == 0 and not self.currently_dead: + self.currently_dead = True + + # Check if we are in Dracula II's arena. The game considers this part of the Sealed Room area, + # which I don't think makes sense to be player-facing like this. + if area == AREA_SEALED_ROOM and room == 2: + area_of_death = "Dracula's realm" + # If we aren't in Dracula II's arena, then take the name of whatever area the player is currently in. + else: + area_of_death = DEATHLINK_AREA_NAMES[area] + + await ctx.send_death(f"{ctx.player_names[ctx.slot]} perished in {area_of_death}. Dracula has won!") + + # Update the Dracula II and Battle Arena events already being done on past separate sessions for if the + # player is running the Battle Arena and Dracula goal. + if f"castlevania_cotm_events_{ctx.team}_{ctx.slot}" in ctx.stored_data: + if ctx.stored_data[f"castlevania_cotm_events_{ctx.team}_{ctx.slot}"] is not None: + if ctx.stored_data[f"castlevania_cotm_events_{ctx.team}_{ctx.slot}"] & 0x2: + self.won_battle_arena = True + + if ctx.stored_data[f"castlevania_cotm_events_{ctx.team}_{ctx.slot}"] & 0x800: + self.killed_dracula_2 = True + + # If we won the Battle Arena, haven't seen the win message yet, and are in the Arena at the moment, pop up + # the win message while playing the game's unused Theme of Simon Belmont fanfare. + if self.won_battle_arena and not self.saw_arena_win_message and area == AREA_BATTLE_ARENA \ + and ok_to_inject and not self.currently_dead: + win_message = cvcotm_string_to_bytearray(" A 「WINNER」 IS 「YOU」!▶", "little middle", 0, + wrap=False) + await bizhawk.write(ctx.bizhawk_ctx, [(QUEUED_TEXTBOX_1_ADDRESS, TEXT_ID_MULTIWORLD_MESSAGE, "EWRAM"), + (QUEUED_SOUND_ID_ADDRESS, SOUND_ID_UNUSED_SIMON_FANFARE, "EWRAM"), + (QUEUED_TEXT_STRING_START, win_message, "ROM")]) + self.saw_arena_win_message = True + + # If we have any queued death causes, handle DeathLink giving here. + elif self.death_causes and ok_to_inject and not self.currently_dead: + + # Inject the oldest cause as a textbox message and play the Dracula charge attack sound. + death_text = self.death_causes[0] + death_writes = [(QUEUED_TEXTBOX_1_ADDRESS, TEXT_ID_MULTIWORLD_MESSAGE, "EWRAM"), + (QUEUED_SOUND_ID_ADDRESS, SOUND_ID_DRACULA_CHARGE, "EWRAM")] + + # If we are in the Battle Arena and are not using the On Including Arena DeathLink option, extend the + # DeathLink message and don't actually kill Nathan. + if ctx.slot_data["death_link"] != CVCotMDeathLink.option_arena_on and area == AREA_BATTLE_ARENA: + death_text += "◊The Battle Arena nullified the DeathLink. Go fight fair and square!" + else: + # Otherwise, kill Nathan by giving him a 9999 damage-dealing poison status that hurts him as soon as + # the death cause textbox is dismissed. + death_writes += [(CURRENT_STATUS_ADDRESS, STATUS_POISON, "EWRAM"), + (POISON_TIMER_TILL_DAMAGE_ADDRESS, b"\x38", "EWRAM"), + (POISON_DAMAGE_VALUE_ADDRESS, b"\x0F\x27", "EWRAM")] + + # Add the final death text and write the whole shebang. + death_writes += [(QUEUED_TEXT_STRING_START, + bytes(cvcotm_string_to_bytearray(death_text + "◊", "big middle", 0)), "ROM")] + await bizhawk.write(ctx.bizhawk_ctx, death_writes) + + # Delete the oldest death cause that we just wrote and set currently_dead to True so the client doesn't + # think we just died on our own on the subsequent frames before the Game Over state. + del(self.death_causes[0]) + self.currently_dead = True + + # If we have a queue of Locations to inject "sent" messages with, do so before giving any subsequent Items. + elif self.sent_message_queue and ok_to_inject and not self.currently_dead and ctx.locations_info: + loc = self.sent_message_queue[0] + # Truncate the Item name. ArchipIDLE's FFXIV Item is 214 characters, for comparison. + item_name = ctx.item_names.lookup_in_slot(ctx.locations_info[loc].item, ctx.locations_info[loc].player) + if len(item_name) > ITEM_NAME_LIMIT: + item_name = item_name[:ITEM_NAME_LIMIT] + # Truncate the player name. Player names are normally capped at 16 characters, but there is no limit on + # ItemLink group names. + player_name = ctx.player_names[ctx.locations_info[loc].player] + if len(player_name) > PLAYER_NAME_LIMIT: + player_name = player_name[:PLAYER_NAME_LIMIT] + + sent_text = cvcotm_string_to_bytearray(f"「{item_name}」 sent to 「{player_name}」◊", "big middle", 0) + + # Set the correct sound to play depending on the Item's classification. + if item_name == iname.ironmaidens and \ + ctx.slot_info[ctx.locations_info[loc].player].game == "Castlevania - Circle of the Moon": + mssg_sfx_id = SOUND_ID_MAIDEN_BREAKING + sent_text = cvcotm_string_to_bytearray(f"「Iron Maidens」 broken for 「{player_name}」◊", + "big middle", 0) + elif ctx.locations_info[loc].flags & MAJORS_CLASSIFICATIONS: + mssg_sfx_id = SOUND_ID_MAJOR_PICKUP + elif ctx.locations_info[loc].flags & ItemClassification.trap: + mssg_sfx_id = SOUND_ID_BAD_CONFIG + else: # Filler + mssg_sfx_id = SOUND_ID_MINOR_PICKUP + + await bizhawk.write(ctx.bizhawk_ctx, [(QUEUED_TEXTBOX_1_ADDRESS, TEXT_ID_MULTIWORLD_MESSAGE, "EWRAM"), + (QUEUED_SOUND_ID_ADDRESS, mssg_sfx_id, "EWRAM"), + (QUEUED_TEXT_STRING_START, sent_text, "ROM")]) + + del(self.sent_message_queue[0]) + + # If the game hasn't received all items yet, it's ok to inject, and the current number of received items + # still matches what we read before, then write the next incoming item into the inventory and, separately, + # the textbox ID to trigger the multiworld textbox, sound effect to play when the textbox opens, number to + # increment the received items count by, and the text to go into the multiworld textbox. The game will then + # do the rest when it's able to. + elif num_received_items < len(ctx.items_received) and ok_to_inject and not self.currently_dead: + next_item = ctx.items_received[num_received_items] + + # Figure out what inventory array and offset from said array to increment based on what we are + # receiving. + flag_index = 0 + flag_array = b"" + inv_array = [] + inv_array_start = 0 + text_id_2 = b"\x00\x00" + item_type = next_item.item & 0xFF00 + inv_array_index = next_item.item & 0xFF + if item_type == 0xE600: # Card + inv_array_start = CARDS_ARRAY_START + inv_array = cards_array + mssg_sfx_id = SOUND_ID_MAJOR_PICKUP + # If skip_tutorials is off and the saw DSS tutorial flag is not set, set the flag and display it + # for the second textbox. + if not self.saw_dss_tutorial and not ctx.slot_data["skip_tutorials"]: + flag_index = FLAG_SAW_DSS_TUTORIAL + flag_array = event_flags_array + text_id_2 = TEXT_ID_DSS_TUTORIAL + elif item_type == 0xE800 and inv_array_index == 0x09: # Maiden Detonator + flag_index = FLAG_HIT_IRON_MAIDEN_SWITCH + flag_array = event_flags_array + mssg_sfx_id = SOUND_ID_MAIDEN_BREAKING + elif item_type == 0xE800: # Any other Magic Item + inv_array_start = MAGIC_ITEMS_ARRAY_START + inv_array = magic_items_array + mssg_sfx_id = SOUND_ID_MAJOR_PICKUP + if inv_array_index > 5: # The unused Map's index is skipped over. + inv_array_index -= 1 + else: # Max Up + inv_array_start = MAX_UPS_ARRAY_START + mssg_sfx_id = SOUND_ID_MINOR_PICKUP + inv_array = max_ups_array + + item_name = ctx.item_names.lookup_in_slot(next_item.item) + player_name = ctx.player_names[next_item.player] + # Truncate the player name. + if len(player_name) > PLAYER_NAME_LIMIT: + player_name = player_name[:PLAYER_NAME_LIMIT] + + # If the Item came from a different player, display a custom received message. Otherwise, display the + # vanilla received message for that Item. + if next_item.player != ctx.slot: + text_id_1 = TEXT_ID_MULTIWORLD_MESSAGE + if item_name == iname.ironmaidens: + received_text = cvcotm_string_to_bytearray(f"「Iron Maidens」 broken by " + f"「{player_name}」◊", "big middle", 0) + else: + received_text = cvcotm_string_to_bytearray(f"「{item_name}」 received from " + f"「{player_name}」◊", "big middle", 0) + text_write = [(QUEUED_TEXT_STRING_START, bytes(received_text), "ROM")] + + # If skip_tutorials is off, display the Item's tutorial for the second textbox (if it has one). + if not ctx.slot_data["skip_tutorials"] and cvcotm_item_info[item_name].tutorial_id is not None: + text_id_2 = cvcotm_item_info[item_name].tutorial_id + else: + text_id_1 = cvcotm_item_info[item_name].text_id + text_write = [] + + # Check if the player has 255 of the item being received. If they do, don't increment that counter + # further. + refill_write = [] + count_write = [] + flag_write = [] + count_guard = [] + flag_guard = [] + + # If there's a value to increment in an inventory array, do so here after checking to see if we can. + if inv_array_start: + if inv_array[inv_array_index] + 1 > 0xFF: + # If it's a stat max up being received, manually give a refill of that item's stat. + # Normally, the game does this automatically by incrementing the number of that max up. + if item_name == iname.hp_max: + refill_write = [(CURRENT_HP_ADDRESS, int.to_bytes(max_hp, 2, "little"), "EWRAM")] + elif item_name == iname.mp_max: + refill_write = [(CURRENT_MP_ADDRESS, int.to_bytes(max_mp, 2, "little"), "EWRAM")] + elif item_name == iname.heart_max: + # If adding +6 Hearts doesn't put us over the player's current max Hearts, do so. + # Otherwise, set the player's current Hearts to the current max. + if hearts + 6 > max_hearts: + new_hearts = max_hearts + else: + new_hearts = hearts + 6 + refill_write = [(CURRENT_HEARTS_ADDRESS, int.to_bytes(new_hearts, 2, "little"), "EWRAM")] + else: + # If our received count of that item is not more than 255, increment it normally. + inv_address = inv_array_start + inv_array_index + count_guard = [(inv_address, int.to_bytes(inv_array[inv_array_index], 1, "little"), "EWRAM")] + count_write = [(inv_address, int.to_bytes(inv_array[inv_array_index] + 1, 1, "little"), + "EWRAM")] + + # If there's a flag value to set, do so here. + if flag_index: + flag_bytearray_index = flag_index // 8 + flag_address = FLAGS_ARRAY_START + flag_bytearray_index + flag_guard = [(flag_address, int.to_bytes(flag_array[flag_bytearray_index], 1, "little"), "EWRAM")] + flag_write = [(flag_address, int.to_bytes(flag_array[flag_bytearray_index] | + (0x01 << (flag_index % 8)), 1, "little"), "EWRAM")] + + await bizhawk.guarded_write(ctx.bizhawk_ctx, + [(QUEUED_TEXTBOX_1_ADDRESS, text_id_1, "EWRAM"), + (QUEUED_TEXTBOX_2_ADDRESS, text_id_2, "EWRAM"), + (QUEUED_MSG_DELAY_TIMER_ADDRESS, b"\x01", "EWRAM"), + (QUEUED_SOUND_ID_ADDRESS, mssg_sfx_id, "EWRAM")] + + count_write + flag_write + text_write + refill_write, + # Make sure the number of received items and number to overwrite are still + # what we expect them to be. + [(NUM_RECEIVED_ITEMS_ADDRESS, read_state[3], "EWRAM")] + + count_guard + flag_guard), + + locs_to_send = set() + + # Check each bit in each flag byte for set Location and event flags. + checked_set_events = {flag_name: False for flag, flag_name in EVENT_FLAG_MAP.items()} + for byte_index, byte in enumerate(event_flags_array): + for i in range(8): + and_value = 0x01 << i + if byte & and_value != 0: + flag_id = byte_index * 8 + i + + location_id = flag_id + BASE_ID + if location_id in ctx.server_locations: + locs_to_send.add(location_id) + + # If the flag for pressing the Iron Maiden switch is set, and the Iron Maiden behavior is + # vanilla (meaning we really pressed the switch), send the Iron Maiden switch as checked. + if flag_id == FLAG_HIT_IRON_MAIDEN_SWITCH and ctx.slot_data["iron_maiden_behavior"] == \ + IronMaidenBehavior.option_vanilla: + locs_to_send.add(cvcotm_location_info[lname.ct21].code + BASE_ID) + + # If the DSS tutorial flag is set, let the client know, so it's not shown again for + # subsequently-received cards. + if flag_id == FLAG_SAW_DSS_TUTORIAL: + self.saw_dss_tutorial = True + + if flag_id in EVENT_FLAG_MAP: + checked_set_events[EVENT_FLAG_MAP[flag_id]] = True + + # Update the client's statuses for the Battle Arena and Dracula goals. + if flag_id == FLAG_WON_BATTLE_ARENA: + self.won_battle_arena = True + + if flag_id == FLAG_DEFEATED_DRACULA_II: + self.killed_dracula_2 = True + + # Send Locations if there are any to send. + if locs_to_send != self.local_checked_locations: + self.local_checked_locations = locs_to_send + + if locs_to_send is not None: + # Capture all the Locations with non-local Items to send that are in ctx.missing_locations + # (the ones that were definitely never sent before). + if ctx.locations_info: + self.sent_message_queue += [loc for loc in locs_to_send if loc in ctx.missing_locations and + ctx.locations_info[loc].player != ctx.slot] + # If we still don't have the locations info at this point, send another LocationScout packet just + # in case something went wrong, and we never received the initial LocationInfo packet. + else: + await ctx.send_msgs([{ + "cmd": "LocationScouts", + "locations": [code for name, code in get_location_names_to_ids().items() + if code in ctx.server_locations], + "create_as_hint": 0 + }]) + + await ctx.send_msgs([{ + "cmd": "LocationChecks", + "locations": list(locs_to_send) + }]) + + # Check the win condition depending on what our completion goal is. + # The Dracula option requires the "killed Dracula II" flag to be set or being in the credits state. + # The Battle Arena option requires the Shinning Armor pickup flag to be set. + # Otherwise, the Battle Arena and Dracula option requires both of the above to be satisfied simultaneously. + if ctx.slot_data["completion_goal"] == CompletionGoal.option_dracula: + win_condition = self.killed_dracula_2 + elif ctx.slot_data["completion_goal"] == CompletionGoal.option_battle_arena: + win_condition = self.won_battle_arena + else: + win_condition = self.killed_dracula_2 and self.won_battle_arena + + # Send game clear if we've satisfied the win condition. + if not ctx.finished_game and win_condition: + ctx.finished_game = True + await ctx.send_msgs([{ + "cmd": "StatusUpdate", + "status": ClientStatus.CLIENT_GOAL + }]) + + # Update the tracker event flags + if checked_set_events != self.client_set_events and ctx.slot is not None: + event_bitfield = 0 + for index, (flag, flag_name) in enumerate(EVENT_FLAG_MAP.items()): + if checked_set_events[flag_name]: + event_bitfield |= 1 << index + + await ctx.send_msgs([{ + "cmd": "Set", + "key": f"castlevania_cotm_events_{ctx.team}_{ctx.slot}", + "default": 0, + "want_reply": False, + "operations": [{"operation": "or", "value": event_bitfield}], + }]) + self.client_set_events = checked_set_events + + except bizhawk.RequestFailedError: + # Exit handler and return to main loop to reconnect. + pass diff --git a/worlds/cvcotm/cvcotm_text.py b/worlds/cvcotm/cvcotm_text.py new file mode 100644 index 000000000000..803435a5fce8 --- /dev/null +++ b/worlds/cvcotm/cvcotm_text.py @@ -0,0 +1,178 @@ +from typing import Literal + +cvcotm_char_dict = {"\n": 0x09, " ": 0x26, "!": 0x4A, '"': 0x78, "#": 0x79, "$": 0x7B, "%": 0x68, "&": 0x73, "'": 0x51, + "(": 0x54, ")": 0x55, "*": 0x7A, "+": 0x50, ",": 0x4C, "-": 0x58, ".": 0x35, "/": 0x70, "0": 0x64, + "1": 0x6A, "2": 0x63, "3": 0x6C, "4": 0x71, "5": 0x69, "6": 0x7C, "7": 0x7D, "8": 0x72, "9": 0x85, + ":": 0x86, ";": 0x87, "<": 0x8F, "=": 0x90, ">": 0x91, "?": 0x48, "@": 0x98, "A": 0x3E, "B": 0x4D, + "C": 0x44, "D": 0x45, "E": 0x4E, "F": 0x56, "G": 0x4F, "H": 0x40, "I": 0x43, "J": 0x6B, "K": 0x66, + "L": 0x5F, "M": 0x42, "N": 0x52, "O": 0x67, "P": 0x4B, "Q": 0x99, "R": 0x46, "S": 0x41, "T": 0x47, + "U": 0x60, "V": 0x6E, "W": 0x49, "X": 0x6D, "Y": 0x53, "Z": 0x6F, "[": 0x59, "\\": 0x9A, "]": 0x5A, + "^": 0x9B, "_": 0xA1, "a": 0x29, "b": 0x3C, "c": 0x33, "d": 0x32, "e": 0x28, "f": 0x3A, "g": 0x39, + "h": 0x31, "i": 0x2D, "j": 0x62, "k": 0x3D, "l": 0x30, "m": 0x36, "n": 0x2E, "o": 0x2B, "p": 0x38, + "q": 0x61, "r": 0x2C, "s": 0x2F, "t": 0x2A, "u": 0x34, "v": 0x3F, "w": 0x37, "x": 0x57, "y": 0x3B, + "z": 0x65, "{": 0xA3, "|": 0xA4, "}": 0xA5, "`": 0xA2, "~": 0xAC, + # Special command characters + "▶": 0x02, # Press A with prompt arrow. + "◊": 0x03, # Press A without prompt arrow. + "\t": 0x01, # Clear the text buffer; usually after pressing A to advance. + "\b": 0x0A, # Reset text alignment; usually after pressing A. + "「": 0x06, # Start orange text + "」": 0x07, # End orange text + } + +# Characters that do not contribute to the line length. +weightless_chars = {"\n", "▶", "◊", "\b", "\t", "「", "」"} + + +def cvcotm_string_to_bytearray(cvcotm_text: str, textbox_type: Literal["big top", "big middle", "little middle"], + speed: int, portrait: int = 0xFF, wrap: bool = True, + skip_textbox_controllers: bool = False) -> bytearray: + """Converts a string into a textbox bytearray following CVCotM's string format.""" + text_bytes = bytearray(0) + if portrait == 0xFF and textbox_type != "little middle": + text_bytes.append(0x0C) # Insert the character to convert a 3-line named textbox into a 4-line nameless one. + + # Figure out the start and end params for the textbox based on what type it is. + if textbox_type == "little middle": + main_control_start_param = 0x10 + main_control_end_param = 0x20 + elif textbox_type == "big top": + main_control_start_param = 0x40 + main_control_end_param = 0xC0 + else: + main_control_start_param = 0x80 + main_control_end_param = 0xC0 + + # Figure out the number of lines and line length limit. + if textbox_type == "little middle": + total_lines = 1 + len_limit = 29 + elif textbox_type != "little middle" and portrait != 0xFF: + total_lines = 3 + len_limit = 21 + else: + total_lines = 4 + len_limit = 23 + + # Wrap the text if we are opting to do so. + if wrap: + refined_text = cvcotm_text_wrap(cvcotm_text, len_limit, total_lines) + else: + refined_text = cvcotm_text + + # Add the textbox control characters if we are opting to add them. + if not skip_textbox_controllers: + text_bytes.extend([0x1D, main_control_start_param + (speed & 0xF)]) # Speed should be a value between 0 and 15. + + # Add the portrait (if we are adding one). + if portrait != 0xFF and textbox_type != "little middle": + text_bytes.extend([0x1E, portrait & 0xFF]) + + for i, char in enumerate(refined_text): + if char in cvcotm_char_dict: + text_bytes.extend([cvcotm_char_dict[char]]) + # If we're pressing A to advance, add the text clear and reset alignment characters. + if char in ["▶", "◊"] and not skip_textbox_controllers: + text_bytes.extend([0x01, 0x0A]) + else: + text_bytes.extend([0x48]) + + # Add the characters indicating the end of the whole message. + if not skip_textbox_controllers: + text_bytes.extend([0x1D, main_control_end_param, 0x00]) + else: + text_bytes.extend([0x00]) + return text_bytes + + +def cvcotm_text_truncate(cvcotm_text: str, textbox_len_limit: int) -> str: + """Truncates a string at a given in-game text line length.""" + line_len = 0 + + for i in range(len(cvcotm_text)): + if cvcotm_text[i] not in weightless_chars: + line_len += 1 + + if line_len > textbox_len_limit: + return cvcotm_text[0x00:i] + + return cvcotm_text + + +def cvcotm_text_wrap(cvcotm_text: str, textbox_len_limit: int, total_lines: int = 4) -> str: + """Rebuilds a string with some of its spaces replaced with newlines to ensure the text wraps properly in an in-game + textbox of a given length. If the number of lines allowed per textbox is exceeded, an A prompt will be placed + instead of a newline.""" + words = cvcotm_text.split(" ") + new_text = "" + line_len = 0 + num_lines = 1 + + for word_index, word in enumerate(words): + # Reset the word length to 0 on every word iteration and make its default divider a space. + word_len = 0 + word_divider = " " + + # Check if we're at the very beginning of a line and handle the situation accordingly by increasing the current + # line length to account for the space if we are not. Otherwise, the word divider should be nothing. + if line_len != 0: + line_len += 1 + else: + word_divider = "" + + new_word = "" + + for char_index, char in enumerate(word): + # Check if the current character contributes to the line length. + if char not in weightless_chars: + line_len += 1 + word_len += 1 + + # If we're looking at a manually-placed newline, add +1 to the lines counter and reset the length counters. + if char == "\n": + word_len = 0 + line_len = 0 + num_lines += 1 + # If this puts us over the line limit, insert the A advance prompt character. + if num_lines > total_lines: + num_lines = 1 + new_word += "▶" + + # If we're looking at a manually-placed A advance prompt, reset the lines and length counters. + if char in ["▶", "◊"]: + word_len = 0 + line_len = 0 + num_lines = 1 + + # If the word alone is long enough to exceed the line length, wrap without moving the entire word. + if word_len > textbox_len_limit: + word_len = 1 + line_len = 1 + num_lines += 1 + word_splitter = "\n" + + # If this puts us over the line limit, replace the newline with the A advance prompt character. + if num_lines > total_lines: + num_lines = 1 + word_splitter = "▶" + + new_word += word_splitter + + # If the total length of the current line exceeds the line length, wrap the current word to the next line. + if line_len > textbox_len_limit: + word_divider = "\n" + line_len = word_len + num_lines += 1 + # If we're over the allowed number of lines to be displayed in the textbox, insert the A advance + # character instead. + if num_lines > total_lines: + num_lines = 1 + word_divider = "▶" + + # Add the character to the new word if the character is not a newline immediately following up an A advance. + if char != "\n" or new_word[len(new_word)-1] not in ["▶", "◊"]: + new_word += char + + new_text += word_divider + new_word + + return new_text diff --git a/worlds/cvcotm/data/iname.py b/worlds/cvcotm/data/iname.py new file mode 100644 index 000000000000..f121217fdf20 --- /dev/null +++ b/worlds/cvcotm/data/iname.py @@ -0,0 +1,36 @@ +double = "Double" +tackle = "Tackle" +kick_boots = "Kick Boots" +heavy_ring = "Heavy Ring" +cleansing = "Cleansing" +roc_wing = "Roc Wing" +last_key = "Last Key" +ironmaidens = "Maiden Detonator" + +heart_max = "Heart Max Up" +mp_max = "MP Max Up" +hp_max = "HP Max Up" + +salamander = "Salamander Card" +serpent = "Serpent Card" +mandragora = "Mandragora Card" +golem = "Golem Card" +cockatrice = "Cockatrice Card" +manticore = "Manticore Card" +griffin = "Griffin Card" +thunderbird = "Thunderbird Card" +unicorn = "Unicorn Card" +black_dog = "Black Dog Card" +mercury = "Mercury Card" +venus = "Venus Card" +jupiter = "Jupiter Card" +mars = "Mars Card" +diana = "Diana Card" +apollo = "Apollo Card" +neptune = "Neptune Card" +saturn = "Saturn Card" +uranus = "Uranus Card" +pluto = "Pluto Card" + +dracula = "The Count Downed" +shinning_armor = "Where's My Super Suit?" diff --git a/worlds/cvcotm/data/ips/AllowAlwaysDrop.ips b/worlds/cvcotm/data/ips/AllowAlwaysDrop.ips new file mode 100644 index 0000000000000000000000000000000000000000..ece911545e907c7bdb2ccbcbc8108053f3feed49 GIT binary patch literal 67 zcmWG=3~}~gw9R2)5%y?zW3=7Fz`~%M&XKOnz@Wn7HN}bf0;`vqB(ukKHx@57ndxo< V>I@7W!VC-rwu~HQKqR#?vkG=S#*54z?QRSe5)xL1 SOdw?pS$o(x0)W`n-wgl}8xREm literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/BrokenMaidens.ips b/worlds/cvcotm/data/ips/BrokenMaidens.ips new file mode 100644 index 0000000000000000000000000000000000000000..84cee2c227bdfb01a1cde17c4ab1c47398eb1af3 GIT binary patch literal 54 zcmWG=3~}~gsBdB5VDRK{XH-b%NLOHBkYVv)QDiJIkeOq^py&|CX6(z!>G~?*;%-Tn{k- literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/BuffSubweapons.ips b/worlds/cvcotm/data/ips/BuffSubweapons.ips new file mode 100644 index 0000000000000000000000000000000000000000..373c1d425cfc13b7caddea85075eb14bfb0423af GIT binary patch literal 68 zcmWG=3~}~gdl|vNn8Nlkih;3~oqZnzdx|2X2E)Z(cCHHy?6qL_Ja)bX4D9w`b_%0<$&7(H&=f*05<6cIsgCw literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/CardUp_v3_Custom2.ips b/worlds/cvcotm/data/ips/CardUp_v3_Custom2.ips new file mode 100644 index 0000000000000000000000000000000000000000..e73ece2fbd06cf3d1e8f4f62bee5a9e5929e99da GIT binary patch literal 348 zcmWG=3~}~geHFmKbij@EdkO;wgC~dk!S-~H^!5V`CJLPj9e#=dK?xl}K=MIH2#{3h z324h^E-nmej0#LZHUoo79RtUOSF9YjfSC2Q3(ypxa3a{0 z1O^2LC60m_SxyBZiit5InvA!ZfL1Vg_PhDKX60xDV%Ap+7?{|qSYJmlFlhj(Dh4La zD%R%}Kx4e;yLo`U$=j2BqE7#=WrffP18XXV(y zz{}wP)W84+AOHJaHfi|rA4Ib@`~lMbP&yD!i-P5YFRL^#Fns(Ud|9-?)!z*OD0+C7 literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/Countdown.ips b/worlds/cvcotm/data/ips/Countdown.ips new file mode 100644 index 0000000000000000000000000000000000000000..4da69502a94093ee4b36ebaa0998912499b4a43a GIT binary patch literal 240 zcmWG=3~}~gQ<}!W#puD{&cIM>kj{~Az`$^Y&6A^oUxV@Pe+AYD3>wTAJu8?r87}@; zC;%$@6U@X^utnIjfx#()LG`}^8xxzeK*t6r$qNU$88mq>^7=S(b}}SK$SX2BFe-F7 zNH{QPFkECbWiopGoZZKP#eqjLh5=|ANaWFT4Vi*HKvPZ9IZA-$mV_}e{r`U8|9=gZ ziwp_^o*WGx4Z;upe?R`;k-1$V{` l+Y~z)awRk?PBJh9y>sUhFUJf92DX`e3>*uU*f?DM-2h23iG s3``l188{r2R>Zg)D8?{oGG1H|vm(S1$PWbbUxE40pS$|I)&Ktw0H9AD-2eap literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/DSSRunSpeed.ips b/worlds/cvcotm/data/ips/DSSRunSpeed.ips new file mode 100644 index 0000000000000000000000000000000000000000..33c29c2367981cb5939489cc296309093d03844f GIT binary patch literal 18 ZcmWG=3~~10V-;dxWny4pU~%<#0{|M>0($@e literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/DemoForceFirst.ips b/worlds/cvcotm/data/ips/DemoForceFirst.ips new file mode 100644 index 0000000000000000000000000000000000000000..26e12794d34b3ca0f0e00caa4158d60dbfadcb39 GIT binary patch literal 15 WcmWG=3~}~gWYJ(?VpMeXcLM+xg94lY literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/DropReworkMultiEdition.ips b/worlds/cvcotm/data/ips/DropReworkMultiEdition.ips new file mode 100644 index 0000000000000000000000000000000000000000..a11303195626c3f3049778697b655128879b7ac4 GIT binary patch literal 783 zcmX|9Pe>F|82{eNbv%`PNUOtga*BN?ql;34hl7P;~oL=+?_3VTteZ&tU{_kO>R_x--#@4b;5$r)I@1KG=Fzd+&(QPCL=8!yo2jr1_ymUT; z;0$FV8i@V9vPWP1*#vQI&ht2s;-a*u3Vg`1d?_8tuN=!%TL%*)QI&GgsZDZ;tCc}% zhZ?j7a5y6f!adR>b{+m8DK@BW2pcp5z$K=N3&KJqa;J~bWDYv=kSNTzkrZWM(QTno zGaS{tQ(;w`GL4rq zQ9r#d8DV`hlnphv49TlnP=!{P>9YZqt8I#_3^QFXD(-K>^n8(KE5(X$8R^FMe<4m! z&Zgwxjho#`tghP)yDcl3Iu9W`vb6RE5P)YTq2-$dc)!|d<1}ol{X%3+WGiW z5bjPWbk!{7AN#ccEZ9*~q<73LjfVB!vvI6l0SB-xj$DCbfJ$Ktl&qo$!J(-LgjuK% zYv@>9&ic=uJKbdo)tV7T8b2{k??9%}w}|3dO%uO0kW!jWdDe${$qnmA|AP`RD1e)< Lfhri+&{O9hGmg#u literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/GameClearBypass.ips b/worlds/cvcotm/data/ips/GameClearBypass.ips new file mode 100644 index 0000000000000000000000000000000000000000..5fc351b3972355dc91948a420681c28278e934d3 GIT binary patch literal 29 jcmWG=3~}~gm_LbuNluwz|0N)$$S^UDfr){^)!z*OYug5j literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/MPComboFix.ips b/worlds/cvcotm/data/ips/MPComboFix.ips new file mode 100644 index 0000000000000000000000000000000000000000..4c7dfab36e5795fecd288965458714a6b4e2658c GIT binary patch literal 15 WcmWG=3~~10^H{;a#K7R{?*;%I-~&ql literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/MapEdits.ips b/worlds/cvcotm/data/ips/MapEdits.ips new file mode 100644 index 0000000000000000000000000000000000000000..4e4c07551698ce221179b731aa2b7f6650321c4e GIT binary patch literal 32 lcmWG=3~}~|(Obj7%+0_Uqo2UQ%*enMdno`!bGZ7u0RV051{44Q literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/MultiLastKey.ips b/worlds/cvcotm/data/ips/MultiLastKey.ips new file mode 100644 index 0000000000000000000000000000000000000000..20df85d1c91fccaf6794930013064a8546d08784 GIT binary patch literal 191 zcmWG=3~}~gIGx5|X5`1<(e9=opU!c>4HGgb7|LjQF@JdctcRm4;yTG>W0^72?10=Hh0GPZ1BKHJ< oNwCR#PJq~ZMHpBa7=AJ6@a(l=VAY1OG8h=OdG^)-NmqY204o798~^|S literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/NoMPDrain.ips b/worlds/cvcotm/data/ips/NoMPDrain.ips new file mode 100644 index 0000000000000000000000000000000000000000..7481a63b556cbf2c09e957d8837ef4416695b90d GIT binary patch literal 17 YcmWG=3~}~gJ28cU#qEIG0at%F05I|ey#N3J literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/PermanentDash.ips b/worlds/cvcotm/data/ips/PermanentDash.ips new file mode 100644 index 0000000000000000000000000000000000000000..458c8c935ac9d1bfa6a969a8f70398163bf7199b GIT binary patch literal 33 ncmWG=3~}~gGw@(wWMJFq!N7FDjcsEh1LFg>jSWB{SARDEf`SMF literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/SeedDisplay20Digits.ips b/worlds/cvcotm/data/ips/SeedDisplay20Digits.ips new file mode 100644 index 0000000000000000000000000000000000000000..ffcd22972d0f6add0c5ab34306e07cf30531e4b1 GIT binary patch literal 110 zcmWG=3~}~gIHkbA!Qj#E#-Nzak*>(V5c12AQGug?&5O-bL|LGNqk++y$%x62LHoxY zW{-`E?QS|ig+VzCY#OH+ICKN_0`vn60t^F;0*nJp0!#zU0?Y#}0xScp0;~gU0&D~9 M0_+1E0$lyw0Ge1E00000 literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/ShooterStrength.ips b/worlds/cvcotm/data/ips/ShooterStrength.ips new file mode 100644 index 0000000000000000000000000000000000000000..865d201c387cf3aad887278511311844b0de5bfa GIT binary patch literal 16 XcmWG=3~~10hA^sB1{9$ literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/ips/SoftlockBlockFix.ips b/worlds/cvcotm/data/ips/SoftlockBlockFix.ips new file mode 100644 index 0000000000000000000000000000000000000000..5f4f4b902b5f5c08eb016a91aeac64ce431da840 GIT binary patch literal 15 WcmWG=3~~10)%w7|#8~g@?*;%M2?O*1 literal 0 HcmV?d00001 diff --git a/worlds/cvcotm/data/lname.py b/worlds/cvcotm/data/lname.py new file mode 100644 index 000000000000..4ef312f2fa23 --- /dev/null +++ b/worlds/cvcotm/data/lname.py @@ -0,0 +1,128 @@ +sr3 = "Sealed Room: Main shaft left fake wall" +cc1 = "Catacomb: Push crate treasure room" +cc3 = "Catacomb: Fleamen brain room - Lower" +cc3b = "Catacomb: Fleamen brain room - Upper" +cc4 = "Catacomb: Earth Demon dash room" +cc5 = "Catacomb: Tackle block treasure room" +cc8 = "Catacomb: Earth Demon bone pit - Lower" +cc8b = "Catacomb: Earth Demon bone pit - Upper" +cc9 = "Catacomb: Below right column save room" +cc10 = "Catacomb: Right column fake wall" +cc13 = "Catacomb: Right column Spirit room" +cc14 = "Catacomb: Muddy Mudman platforms room - Lower" +cc14b = "Catacomb: Muddy Mudman platforms room - Upper" +cc16 = "Catacomb: Slide space zone" +cc20 = "Catacomb: Pre-Cerberus lone Skeleton room" +cc22 = "Catacomb: Pre-Cerberus Hopper treasure room" +cc24 = "Catacomb: Behind Cerberus" +cc25 = "Catacomb: Mummies' fake wall" +as2 = "Abyss Staircase: Lower fake wall" +as3 = "Abyss Staircase: Loopback drop" +as4 = "Abyss Staircase: Roc ledge" +as9 = "Abyss Staircase: Upper fake wall" +ar4 = "Audience Room: Skeleton foyer fake wall" +ar7 = "Audience Room: Main gallery fake wall" +ar8 = "Audience Room: Below coyote jump" +ar9 = "Audience Room: Push crate gallery" +ar10 = "Audience Room: Past coyote jump" +ar11 = "Audience Room: Tackle block gallery" +ar14 = "Audience Room: Wicked roc chamber - Lower" +ar14b = "Audience Room: Wicked roc chamber - Upper" +ar16 = "Audience Room: Upper Devil Tower hallway" +ar17 = "Audience Room: Right exterior - Lower" +ar17b = "Audience Room: Right exterior - Upper" +ar18 = "Audience Room: Right exterior fake wall" +ar19 = "Audience Room: 100 meter skelly dash hallway" +ar21 = "Audience Room: Lower Devil Tower hallway fake wall" +ar25 = "Audience Room: Behind Necromancer" +ar26 = "Audience Room: Below Machine Tower roc ledge" +ar27 = "Audience Room: Below Machine Tower push crate room" +ar30 = "Audience Room: Roc horse jaguar armory - Left" +ar30b = "Audience Room: Roc horse jaguar armory - Right" +ow0 = "Outer Wall: Left roc ledge" +ow1 = "Outer Wall: Right-brained ledge" +ow2 = "Outer Wall: Fake Nightmare floor" +th1 = "Triumph Hallway: Skeleton slopes fake wall" +th3 = "Triumph Hallway: Entrance Flame Armor climb" +mt0 = "Machine Tower: Foxy platforms ledge" +mt2 = "Machine Tower: Knight fox meeting room" +mt3 = "Machine Tower: Boneheaded argument wall kicks room" +mt4 = "Machine Tower: Foxy fake wall" +mt6 = "Machine Tower: Skelly-rang wall kicks room" +mt8 = "Machine Tower: Fake Lilim wall" +mt10 = "Machine Tower: Thunderous zone fake wall" +mt11 = "Machine Tower: Thunderous zone lone Stone Armor room" +mt13 = "Machine Tower: Top lone Stone Armor room" +mt14 = "Machine Tower: Stone fox hallway" +mt17 = "Machine Tower: Pre-Iron Golem fake wall" +mt19 = "Machine Tower: Behind Iron Golem" +ec5 = "Eternal Corridor: Midway fake wall" +ec7 = "Eternal Corridor: Skelly-rang wall kicks room" +ec9 = "Eternal Corridor: Skelly-rang fake wall" +ct1 = "Chapel Tower: Flame Armor climb room" +ct4 = "Chapel Tower: Lower chapel push crate room" +ct5 = "Chapel Tower: Lower chapel fake wall" +ct6 = "Chapel Tower: Beastly wall kicks room - Brain side" +ct6b = "Chapel Tower: Beastly wall kicks room - Brawn side" +ct8 = "Chapel Tower: Middle chapel fake wall" +ct10 = "Chapel Tower: Middle chapel push crate room" +ct13 = "Chapel Tower: Sharp mind climb room" +ct15 = "Chapel Tower: Upper chapel fake wall" +ct16 = "Chapel Tower: Upper chapel Marionette wall kicks" +ct18 = "Chapel Tower: Upper belfry fake wall" +ct21 = "Chapel Tower: Iron maiden switch" +ct22 = "Chapel Tower: Behind Adramelech iron maiden" +ct26 = "Chapel Tower: Outside Battle Arena - Upper" +ct26b = "Chapel Tower: Outside Battle Arena - Lower" +ug0 = "Underground Gallery: Conveyor platform ride" +ug1 = "Underground Gallery: Conveyor upper push crate room" +ug2 = "Underground Gallery: Conveyor lower push crate room" +ug3 = "Underground Gallery: Harpy climb room - Lower" +ug3b = "Underground Gallery: Harpy climb room - Upper" +ug8 = "Underground Gallery: Harpy mantis tackle hallway" +ug10 = "Underground Gallery: Handy bee hallway" +ug13 = "Underground Gallery: Myconid fake wall" +ug15 = "Underground Gallery: Crumble bridge fake wall" +ug20 = "Underground Gallery: Behind Dragon Zombies" +uw1 = "Underground Warehouse: Entrance push crate room" +uw6 = "Underground Warehouse: Forever pushing room" +uw8 = "Underground Warehouse: Crate-nudge fox room" +uw9 = "Underground Warehouse: Crate-nudge fake wall" +uw10 = "Underground Warehouse: Succubus shaft roc ledge" +uw11 = "Underground Warehouse: Fake Lilith wall" +uw14 = "Underground Warehouse: Optional puzzle ceiling fake wall" +uw16 = "Underground Warehouse: Holy fox hideout - Left" +uw16b = "Underground Warehouse: Holy fox hideout - Right roc ledge" +uw19 = "Underground Warehouse: Forest Armor's domain fake wall" +uw23 = "Underground Warehouse: Behind Death" +uw24 = "Underground Warehouse: Behind Death fake wall" +uw25 = "Underground Warehouse: Dryad expulsion chamber" +uy1 = "Underground Waterway: Entrance fake wall" +uy3 = "Underground Waterway: Before illusory wall" +uy3b = "Underground Waterway: Beyond illusory wall" +uy4 = "Underground Waterway: Ice Armor's domain fake wall" +uy5 = "Underground Waterway: Brain freeze room" +uy7 = "Underground Waterway: Middle lone Ice Armor room" +uy8 = "Underground Waterway: Roc fake ceiling" +uy9 = "Underground Waterway: Wicked Fishhead moat - Bottom" +uy9b = "Underground Waterway: Wicked Fishhead moat - Top" +uy12 = "Underground Waterway: Lizard-man turf - Bottom" +uy12b = "Underground Waterway: Lizard-man turf - Top" +uy13 = "Underground Waterway: Roc exit shaft" +uy17 = "Underground Waterway: Behind Camilla" +uy18 = "Underground Waterway: Roc exit shaft fake wall" +ot1 = "Observation Tower: Wind Armor rampart" +ot2 = "Observation Tower: Legion plaza fake wall" +ot3 = "Observation Tower: Legion plaza Minotaur hallway" +ot5 = "Observation Tower: Siren balcony fake wall" +ot8 = "Observation Tower: Evil Pillar pit fake wall" +ot9 = "Observation Tower: Alraune garden" +ot12 = "Observation Tower: Dark Armor's domain fake wall" +ot13 = "Observation Tower: Catoblepeas hallway" +ot16 = "Observation Tower: Near warp room fake wall" +ot20 = "Observation Tower: Behind Hugh" +cr1 = "Ceremonial Room: Fake floor" +ba24 = "Battle Arena: End reward" + +arena_victory = "Arena Victory" +dracula = "Dracula" diff --git a/worlds/cvcotm/data/patches.py b/worlds/cvcotm/data/patches.py new file mode 100644 index 000000000000..c2a9aa791f91 --- /dev/null +++ b/worlds/cvcotm/data/patches.py @@ -0,0 +1,431 @@ +remote_textbox_shower = [ + # Pops up the textbox(s) of whatever textbox IDs is written at 0x02025300 and 0x02025302 and increments the current + # received item index at 0x020253D0 if a number to increment it by is written at 0x02025304. Also plays the sound + # effect of the ID written at 0x02025306, if one is written there. This will NOT give any items on its own; the item + # has to be written by the client into the inventory alongside writing the above-mentioned things. + + # Make sure we didn't hit the lucky one frame before room transitioning wherein Nathan is on top of the room + # transition tile. + 0x0C, 0x88, # ldrh r4, [r1] + 0x80, 0x20, # movs r0, #0x80 + 0x20, 0x40, # ands r0, r4 + 0x00, 0x28, # cmp r0, #0 + 0x2F, 0xD1, # bne 0x87FFF8A + 0x11, 0xB4, # push r0, r4 + # Check the cutscene value to make sure we are not in a cutscene; forcing a textbox while there's already another + # textbox on-screen messes things up. + 0x1E, 0x4A, # ldr r2, =0x2026000 + 0x13, 0x78, # ldrb r3, [r2] + 0x00, 0x2B, # cmp r0, #0 + 0x29, 0xD1, # bne 0x87FFF88 + # Check our "delay" timer buffer for a non-zero. If it is, decrement it by one and skip straight to the return part + # of this code, as we may have received an item on a frame wherein it's "unsafe" to pop the item textbox. + 0x16, 0x4A, # ldr r2, =0x2025300 + 0x13, 0x89, # ldrh r3, [r2, #8] + 0x00, 0x2B, # cmp r0, #0 + 0x02, 0xD0, # beq 0x87FFF42 + 0x01, 0x3B, # subs r3, #1 + 0x13, 0x81, # strh r3, [r2, #8] + 0x22, 0xE0, # beq 0x87FFF88 + # Check our first custom "textbox ID" buffers for a non-zero number. + 0x10, 0x88, # ldrh r0, [r2] + 0x00, 0x28, # cmp r0, #0 + 0x12, 0xD0, # beq 0x87FFF6E + # Increase the "received item index" by the specified number in our "item index amount to increase" buffer. + 0x93, 0x88, # ldrh r3, [r2, #4] + 0xD0, 0x32, # adds r2, #0xD0 + 0x11, 0x88, # ldrh r1, [r2] + 0xC9, 0x18, # adds r1, r1, r3 + 0x11, 0x80, # strh r1, [r2] + # Check our second custom "textbox ID" buffers for a non-zero number. + 0xD0, 0x3A, # subs r2, #0xD0 + 0x51, 0x88, # ldrh r1, [r2, #2] + 0x00, 0x29, # cmp r1, #0 + 0x01, 0xD0, # beq 0x87FFF5E + # If we have a second textbox ID, run the "display two textboxes" function. + # Otherwise, run the "display one textbox" function. + 0x0E, 0x4A, # ldr r2, =0x805F104 + 0x00, 0xE0, # b 0x87FFF60 + 0x0E, 0x4A, # ldr r2, =0x805F0C8 + 0x7B, 0x46, # mov r3, r15 + 0x05, 0x33, # adds r3, #5 + 0x9E, 0x46, # mov r14, r3 + 0x97, 0x46, # mov r15, r2 + 0x09, 0x48, # ldr r0, =0x2025300 + 0x02, 0x21, # movs r1, #2 + 0x01, 0x81, # strh r1, [r0, #8] + # Check our "sound effect ID" buffer and run the "play sound" function if it's a non-zero number. + 0x08, 0x48, # ldr r0, =0x2025300 + 0xC0, 0x88, # ldrh r0, [r0, #6] + 0x00, 0x28, # cmp r0, #0 + 0x04, 0xD0, # beq 0x87FFF7E + 0x0B, 0x4A, # ldr r2, =0x8005E80 + 0x7B, 0x46, # mov r3, r15 + 0x05, 0x33, # adds r3, #5 + 0x9E, 0x46, # mov r14, r3 + 0x97, 0x46, # mov r15, r2 + # Clear all our buffers and return to the "check for Nathan being in a room transition" function we've hooked into. + 0x03, 0x48, # ldr r0, =0x2025300 + 0x00, 0x21, # movs r1, #0 + 0x01, 0x60, # str r1, [r0] + 0x41, 0x60, # str r1, [r0, #4] + 0x11, 0xBC, # pop r0, r4 + 0x04, 0x4A, # ldr r2, =0x8007D68 + 0x00, 0x28, # cmp r0, #0 + 0x97, 0x46, # mov r15, r2 + # LDR number pool + 0x00, 0x53, 0x02, 0x02, + 0x04, 0xF1, 0x05, 0x08, + 0xC8, 0xF0, 0x05, 0x08, + 0x68, 0x7D, 0x00, 0x08, + 0x90, 0x1E, 0x02, 0x02, + 0x80, 0x5E, 0x00, 0x08, + 0x00, 0x60, 0x02, 0x02 +] + +transition_textbox_delayer = [ + # Sets the remote item textbox delay timer whenever the player screen transitions to ensure the item textbox won't + # pop during said transition. + 0x40, 0x78, # ldrb r0, [r0, #1] + 0x28, 0x70, # strb r0, [r5] + 0xF8, 0x6D, # ldr r0, [r7, #0x5C] + 0x20, 0x18, # adds r0, r4, r0 + 0x02, 0x4A, # ldr r2, =0x2025300 + 0x10, 0x23, # movs r3, #0x10 + 0x13, 0x80, # strh r3, [r2] + 0x02, 0x4A, # ldr r2, =0x806CE1C + 0x97, 0x46, # mov r15, r2 + 0x00, 0x00, + # LDR number pool + 0x08, 0x53, 0x02, 0x02, + 0x1C, 0xCE, 0x06, 0x08, +] + +magic_item_sfx_customizer = [ + # Enables a different sound to be played depending on which Magic Item was picked up. The array starting at 086797C0 + # contains each 2-byte sound ID for each Magic Item. Putting 0000 for a sound will cause no sound to play; this is + # currently used for the dummy AP Items as their sound is played by the "sent" textbox instead. + 0x70, 0x68, # ldr r0, [r6, #4] + 0x80, 0x79, # ldrb r0, [r0, #6] + 0x40, 0x00, # lsl r0, r0, 1 + 0x07, 0x49, # ldr r1, =0x86797C0 + 0x08, 0x5A, # ldrh r0, [r1, r0] + 0x00, 0x28, # cmp r0, 0 + 0x04, 0xD0, # beq 0x8679818 + 0x03, 0x4A, # ldr r2, =0x8005E80 + 0x7B, 0x46, # mov r3, r15 + 0x05, 0x33, # adds r3, #5 + 0x9E, 0x46, # mov r14, r3 + 0x97, 0x46, # mov r15, r2 + 0x01, 0x48, # ldr r0, =0x8095BEC + 0x87, 0x46, # mov r15, r0 + # LDR number pool + 0x80, 0x5E, 0x00, 0x08, + 0xEC, 0x5B, 0x09, 0x08, + 0xC0, 0x97, 0x67, 0x08, +] + +start_inventory_giver = [ + # This replaces AutoDashBoots.ips from standalone CotMR by allowing the player to start with any set of items, not + # just the Dash Boots. If playing Magician Mode, they will be given all cards that were not put into the starting + # inventory right after this code runs. + + # Magic Items + 0x13, 0x48, # ldr r0, =0x202572F + 0x14, 0x49, # ldr r1, =0x8680080 + 0x00, 0x22, # mov r2, #0 + 0x8B, 0x5C, # ldrb r3, [r1, r2] + 0x83, 0x54, # strb r3, [r0, r2] + 0x01, 0x32, # adds r2, #1 + 0x08, 0x2A, # cmp r2, #8 + 0xFA, 0xDB, # blt 0x8680006 + # Max Ups + 0x11, 0x48, # ldr r0, =0x202572C + 0x12, 0x49, # ldr r1, =0x8680090 + 0x00, 0x22, # mov r2, #0 + 0x8B, 0x5C, # ldrb r3, [r1, r2] + 0x83, 0x54, # strb r3, [r0, r2] + 0x01, 0x32, # adds r2, #1 + 0x03, 0x2A, # cmp r2, #3 + 0xFA, 0xDB, # blt 0x8680016 + # Cards + 0x0F, 0x48, # ldr r0, =0x2025674 + 0x10, 0x49, # ldr r1, =0x86800A0 + 0x00, 0x22, # mov r2, #0 + 0x8B, 0x5C, # ldrb r3, [r1, r2] + 0x83, 0x54, # strb r3, [r0, r2] + 0x01, 0x32, # adds r2, #1 + 0x14, 0x2A, # cmp r2, #0x14 + 0xFA, 0xDB, # blt 0x8680026 + # Inventory Items (not currently supported) + 0x0D, 0x48, # ldr r0, =0x20256ED + 0x0E, 0x49, # ldr r1, =0x86800C0 + 0x00, 0x22, # mov r2, #0 + 0x8B, 0x5C, # ldrb r3, [r1, r2] + 0x83, 0x54, # strb r3, [r0, r2] + 0x01, 0x32, # adds r2, #1 + 0x36, 0x2A, # cmp r2, #36 + 0xFA, 0xDB, # blt 0x8680036 + # Return to the function that checks for Magician Mode. + 0xBA, 0x21, # movs r1, #0xBA + 0x89, 0x00, # lsls r1, r1, #2 + 0x70, 0x18, # adds r0, r6, r1 + 0x04, 0x70, # strb r4, [r0] + 0x00, 0x4A, # ldr r2, =0x8007F78 + 0x97, 0x46, # mov r15, r2 + # LDR number pool + 0x78, 0x7F, 0x00, 0x08, + 0x2F, 0x57, 0x02, 0x02, + 0x80, 0x00, 0x68, 0x08, + 0x2C, 0x57, 0x02, 0x02, + 0x90, 0x00, 0x68, 0x08, + 0x74, 0x56, 0x02, 0x02, + 0xA0, 0x00, 0x68, 0x08, + 0xED, 0x56, 0x02, 0x02, + 0xC0, 0x00, 0x68, 0x08, +] + +max_max_up_checker = [ + # Whenever the player picks up a Max Up, this will check to see if they currently have 255 of that particular Max Up + # and only increment the number further if they don't. This is necessary for extreme Item Link seeds, as going over + # 255 of any Max Up will reset the counter to 0. + 0x08, 0x78, # ldrb r0, [r1] + 0xFF, 0x28, # cmp r0, 0xFF + 0x17, 0xD1, # bne 0x86A0036 + # If it's an HP Max, refill our HP. + 0xFF, 0x23, # mov r3, #0xFF + 0x0B, 0x40, # and r3, r1 + 0x2D, 0x2B, # cmp r3, 0x2D + 0x03, 0xD1, # bne 0x86A0016 + 0x0D, 0x4A, # ldr r2, =0x202562E + 0x93, 0x88, # ldrh r3, [r2, #4] + 0x13, 0x80, # strh r3, [r2] + 0x11, 0xE0, # b 0x86A003A + # If it's an MP Max, refill our MP. + 0x2E, 0x2B, # cmp r3, 0x2E + 0x03, 0xD1, # bne 0x86A0022 + 0x0B, 0x4A, # ldr r2, =0x2025636 + 0x93, 0x88, # ldrh r3, [r2, #4] + 0x13, 0x80, # strh r3, [r2] + 0x0B, 0xE0, # b 0x86A003A + # Else, meaning it's a Hearts Max, add +6 Hearts. If adding +6 Hearts would put us over our current max, set our + # current amount to said current max instead. + 0x0A, 0x4A, # ldr r2, =0x202563C + 0x13, 0x88, # ldrh r3, [r2] + 0x06, 0x33, # add r3, #6 + 0x51, 0x88, # ldrh r1, [r2, #2] + 0x8B, 0x42, # cmp r3, r1 + 0x00, 0xDB, # blt 0x86A0030 + 0x0B, 0x1C, # add r3, r1, #0 + 0x13, 0x80, # strh r3, [r2] + 0x02, 0xE0, # b 0x86A003A + 0x00, 0x00, + # Increment the Max Up count like normal. Should only get here if the Max Up count was determined to be less than + # 255, branching past if not the case. + 0x01, 0x30, # adds r0, #1 + 0x08, 0x70, # strb r0, [r1] + # Return to the function that gives Max Ups normally. + 0x05, 0x48, # ldr r0, =0x1B3 + 0x00, 0x4A, # ldr r2, =0x805E170 + 0x97, 0x46, # mov r15, r2 + # LDR number pool + 0x78, 0xE1, 0x05, 0x08, + 0x2E, 0x56, 0x02, 0x02, + 0x36, 0x56, 0x02, 0x02, + 0x3C, 0x56, 0x02, 0x02, + 0xB3, 0x01, 0x00, 0x00, +] + +maiden_detonator = [ + # Detonates the iron maidens upon picking up the Maiden Detonator item by setting the "broke iron maidens" flag. + 0x2A, 0x20, # mov r0, #0x2A + 0x03, 0x4A, # ldr r2, =0x8007E24 + 0x7B, 0x46, # mov r3, r15 + 0x05, 0x33, # adds r3, #5 + 0x9E, 0x46, # mov r14, r3 + 0x97, 0x46, # mov r15, r2 + 0x01, 0x4A, # ldr r2, =0x8095BE4 + 0x97, 0x46, # mov r15, r2 + # LDR number pool + 0x24, 0x7E, 0x00, 0x08, + 0xE4, 0x5B, 0x09, 0x08, +] + +doubleless_roc_midairs_preventer = [ + # Prevents being able to Roc jump in midair without the Double. Will pass if the jump counter is 0 or if Double is + # in the inventory. + # Check for Roc Wing in the inventory normally. + 0x58, 0x18, # add r0, r3, r1 + 0x00, 0x78, # ldrb r0, [r0] + 0x00, 0x28, # cmp r0, 0 + 0x11, 0xD0, # beq 0x8679A2C + # Check the "jumps since last on the ground" counter. Is it 0? + # If so, then we are on the ground and can advance to the Kick Boots question. If not, advance to the Double check. + 0x0B, 0x48, # ldr r0, =0x2000080 + 0x01, 0x78, # ldrb r1, [r0] + 0x00, 0x29, # cmp r1, 0 + 0x03, 0xD0, # beq 0x8679A18 + # Check for Double in the inventory. Is it there? + # If not, then it's not time to Roc! Otherwise, advance to the next check. + 0x0A, 0x4A, # ldr r2, =0x202572F + 0x52, 0x78, # ldrb r2, [r2, 1] + 0x00, 0x2A, # cmp r2, 0 + 0x09, 0xD0, # beq 0x8679A2C + # Check for Kick Boots in the inventory. Are they there? + # If they are, then we can definitely Roc! If they aren't, however, then on to the next question... + 0x08, 0x4A, # ldr r2, =0x202572F + 0xD2, 0x78, # ldrb r2, [r2, 3] + 0x00, 0x2A, # cmp r2, 0 + 0x03, 0xD1, # bne 0x8679A28 + # Is our "jumps since last on the ground" counter 2? + # If it is, then we already Double jumped and should not Roc jump as well. + # Should always pass if we came here from the "on the ground" 0 check. + 0x02, 0x29, # cmp r1, 2 + 0x03, 0xD0, # beq 0x8679A2C + # If we did not Double jump yet, then set the above-mentioned counter to 2, and now we can finally Roc on! + 0x02, 0x21, # mov r1, 2 + 0x01, 0x70, # strb r1, [r0] + # Go to the "Roc jump" code. + 0x01, 0x4A, # ldr r2, =0x806B8A8 + 0x97, 0x46, # mov r15, r2 + # Go to the "don't Roc jump" code. + 0x01, 0x4A, # ldr r2, =0x806B93C + 0x97, 0x46, # mov r15, r2 + # LDR number pool + 0xA8, 0xB8, 0x06, 0x08, + 0x3C, 0xB9, 0x06, 0x08, + 0x80, 0x00, 0x00, 0x02, + 0x2F, 0x57, 0x02, 0x02, +] + +kickless_roc_height_shortener = [ + # Shortens the amount of time spent rising with Roc Wing if the player doesn't have Kick Boots. + 0x06, 0x49, # ldr r1, =0x202572F + 0xC9, 0x78, # ldrb r1, [r1, 3] + 0x00, 0x29, # cmp r1, 0 + 0x00, 0xD1, # bne 0x8679A6A + 0x10, 0x20, # mov r0, 0x12 + 0xA8, 0x65, # str r0, [r5, 0x58] + # Go back to the Roc jump code. + 0x00, 0x24, # mov r4, 0 + 0x2C, 0x64, # str r4, [r5, 0x40] + 0x03, 0x49, # ldr r1, =0x80E03A0 + 0x01, 0x4A, # ldr r2, =0x806B8BC + 0x97, 0x46, # mov r15, r2 + 0x00, 0x00, + # LDR number pool + 0xBC, 0xB8, 0x06, 0x08, + 0x2F, 0x57, 0x02, 0x02, + 0xA0, 0x03, 0x0E, 0x08 +] + +missing_char_data = { + # The pixel data for all ASCII characters missing from the game's dialogue textbox font. + + # Each character consists of 8 bytes, with each byte representing one row of pixels in the character. The bytes are + # arranged from top to bottom row going from left to right. + + # Each bit within each byte represents the following pixels within that row: + # 8- = -+------ + # 4- = +------- + # 2- = ---+---- + # 1- = --+----- + # -8 = -----+-- + # -4 = ----+--- + # -2 = -------+ + # -1 = ------+- + 0x396C54: [0x00, 0x9C, 0x9C, 0x18, 0x84, 0x00, 0x00, 0x00], # " + 0x396C5C: [0x00, 0x18, 0xBD, 0x18, 0x18, 0x18, 0xBD, 0x18], # # + 0x396C64: [0x00, 0x0C, 0x2D, 0x0C, 0x21, 0x00, 0x00, 0x00], # * + 0x396C6C: [0x00, 0x20, 0x3C, 0xA0, 0x34, 0x28, 0xB4, 0x20], # $ + 0x396C74: [0x00, 0x34, 0x88, 0x80, 0xB4, 0x88, 0x88, 0x34], # 6 + 0x396C7C: [0x00, 0xBC, 0x88, 0x04, 0x04, 0x20, 0x20, 0x20], # 7 + 0x396CBC: [0x00, 0x34, 0x88, 0x88, 0x3C, 0x08, 0x88, 0x34], # 9 + 0x396CC4: [0x00, 0xC0, 0xC0, 0x00, 0x00, 0x00, 0xC0, 0xC0], # : + 0x396CCC: [0x00, 0xC0, 0xC0, 0x00, 0xC0, 0xC0, 0x80, 0x40], # ; + 0x396D0C: [0x00, 0x00, 0x09, 0x24, 0x90, 0x24, 0x09, 0x00], # < + 0x396D14: [0x00, 0x00, 0xFD, 0x00, 0x00, 0x00, 0xFD, 0x00], # = + 0x396D1C: [0x00, 0x00, 0xC0, 0x30, 0x0C, 0x30, 0xC0, 0x00], # > + 0x396D54: [0x00, 0x34, 0x88, 0xAC, 0xA8, 0xAC, 0x80, 0x34], # @ + 0x396D5C: [0x00, 0x34, 0x88, 0x88, 0xA8, 0x8C, 0x88, 0x35], # Q + 0x396D64: [0x00, 0x40, 0x80, 0x10, 0x20, 0x04, 0x08, 0x01], # \ + 0x396D6C: [0x00, 0x20, 0x14, 0x88, 0x00, 0x00, 0x00, 0x00], # ^ + 0x396D9C: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFD], # _ + 0x396DA4: [0x00, 0x90, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00], # ` + 0x396DAC: [0x00, 0x08, 0x04, 0x04, 0x20, 0x04, 0x04, 0x08], # { + 0x396DB4: [0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20], # | + 0x396DBC: [0x00, 0x80, 0x10, 0x10, 0x20, 0x10, 0x10, 0x80], # } + 0x396DF4: [0x00, 0x00, 0x00, 0x90, 0x61, 0x0C, 0x00, 0x00], # ~ +} + +extra_item_sprites = [ + # The VRAM data for all the extra item sprites, including the Archipelago Items. + + # NOTE: The Archipelago logo is © 2022 by Krista Corkos and Christopher Wilson + # and licensed under Attribution-NonCommercial 4.0 International. + # See LICENSES.txt at the root of this apworld's directory for more licensing information. + + # Maiden Detonator + 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x10, 0xCC, 0x00, 0x00, 0xC1, 0xBB, 0x00, 0x10, 0x1C, 0xB8, + 0x00, 0x10, 0x1C, 0xB1, 0x00, 0x10, 0xBC, 0xBB, 0x00, 0x00, 0x11, 0x11, 0x00, 0x10, 0xCC, 0xBB, + 0x11, 0x00, 0x00, 0x00, 0xCC, 0x01, 0x00, 0x00, 0xBB, 0x1C, 0x00, 0x00, 0x8B, 0xC1, 0x01, 0x00, + 0x1B, 0xC1, 0x01, 0x00, 0xBB, 0xCB, 0x01, 0x00, 0x11, 0x11, 0x00, 0x00, 0xBB, 0xCC, 0x01, 0x00, + 0x00, 0x10, 0x11, 0x11, 0x00, 0xC1, 0xBC, 0x1B, 0x00, 0x10, 0x11, 0x11, 0x00, 0xC1, 0xBC, 0x1B, + 0x00, 0x10, 0x11, 0x11, 0x00, 0xC1, 0xBC, 0x1B, 0x00, 0xC1, 0xBC, 0x1B, 0x00, 0x10, 0x11, 0x01, + 0x11, 0x11, 0x01, 0x00, 0xB1, 0xCB, 0x1C, 0x00, 0x11, 0x11, 0x01, 0x00, 0xB1, 0xCB, 0x1C, 0x00, + 0x11, 0x11, 0x01, 0x00, 0xB1, 0xCB, 0x1C, 0x00, 0xB1, 0xCB, 0x1C, 0x00, 0x10, 0x11, 0x01, 0x00, + # Archipelago Filler + 0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x20, 0x88, 0x00, 0x22, 0x82, 0x88, 0x20, 0x66, 0x26, 0x88, + 0x62, 0x66, 0x62, 0x82, 0x62, 0x66, 0x66, 0x82, 0x62, 0x22, 0x62, 0x22, 0x20, 0xAA, 0x2A, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x82, 0x22, 0x02, 0x00, 0x28, 0xCC, 0x2C, 0x00, + 0xC2, 0xCC, 0xC2, 0x02, 0xC2, 0xCC, 0xCC, 0x02, 0xC2, 0x22, 0xC2, 0x02, 0x20, 0xFF, 0x2F, 0x00, + 0xA2, 0xAA, 0xA2, 0x02, 0xA2, 0xAA, 0xAA, 0x22, 0xA2, 0xAA, 0x2A, 0x77, 0x20, 0xAA, 0x72, 0x77, + 0x00, 0x22, 0x72, 0x77, 0x00, 0x00, 0x72, 0x77, 0x00, 0x00, 0x20, 0x77, 0x00, 0x00, 0x00, 0x22, + 0xF2, 0xFF, 0xF2, 0x02, 0xF2, 0xFF, 0xFF, 0x02, 0x27, 0xFF, 0xFF, 0x02, 0x72, 0xF2, 0x2F, 0x00, + 0x77, 0x22, 0x02, 0x00, 0x77, 0x02, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, + # Archipelago Useful + 0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x20, 0x88, 0x00, 0x22, 0x82, 0x88, 0x20, 0x66, 0x26, 0x88, + 0x62, 0x66, 0x62, 0x82, 0x62, 0x66, 0x66, 0x82, 0x62, 0x22, 0x62, 0x22, 0x20, 0xAA, 0x2A, 0x00, + 0x02, 0xAA, 0x0A, 0x00, 0x28, 0x9A, 0x0A, 0x00, 0xAA, 0x9A, 0xAA, 0x0A, 0x9A, 0x99, 0x99, 0x0A, + 0xAA, 0x9A, 0xAA, 0x0A, 0xC2, 0x9A, 0xCA, 0x02, 0xC2, 0xAA, 0xCA, 0x02, 0x20, 0xFF, 0x2F, 0x00, + 0xA2, 0xAA, 0xA2, 0x02, 0xA2, 0xAA, 0xAA, 0x22, 0xA2, 0xAA, 0x2A, 0x77, 0x20, 0xAA, 0x72, 0x77, + 0x00, 0x22, 0x72, 0x77, 0x00, 0x00, 0x72, 0x77, 0x00, 0x00, 0x20, 0x77, 0x00, 0x00, 0x00, 0x22, + 0xF2, 0xFF, 0xF2, 0x02, 0xF2, 0xFF, 0xFF, 0x02, 0x27, 0xFF, 0xFF, 0x02, 0x72, 0xF2, 0x2F, 0x00, + 0x77, 0x22, 0x02, 0x00, 0x77, 0x02, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, + # Archipelago Progression + 0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x20, 0x88, 0x00, 0x22, 0x82, 0x88, 0x20, 0x66, 0x26, 0x88, + 0x62, 0x66, 0x62, 0x82, 0x62, 0x66, 0x66, 0x82, 0x62, 0x22, 0x62, 0x22, 0x20, 0xAA, 0x2A, 0x00, + 0x02, 0x10, 0x00, 0x00, 0x28, 0x71, 0x01, 0x00, 0x12, 0x77, 0x17, 0x00, 0x71, 0x77, 0x77, 0x01, + 0x11, 0x77, 0x17, 0x01, 0x12, 0x77, 0x17, 0x02, 0x12, 0x11, 0x11, 0x02, 0x20, 0xFF, 0x2F, 0x00, + 0xA2, 0xAA, 0xA2, 0x02, 0xA2, 0xAA, 0xAA, 0x22, 0xA2, 0xAA, 0x2A, 0x77, 0x20, 0xAA, 0x72, 0x77, + 0x00, 0x22, 0x72, 0x77, 0x00, 0x00, 0x72, 0x77, 0x00, 0x00, 0x20, 0x77, 0x00, 0x00, 0x00, 0x22, + 0xF2, 0xFF, 0xF2, 0x02, 0xF2, 0xFF, 0xFF, 0x02, 0x27, 0xFF, 0xFF, 0x02, 0x72, 0xF2, 0x2F, 0x00, + 0x77, 0x22, 0x02, 0x00, 0x77, 0x02, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, + # Archipelago Trap + 0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x20, 0x88, 0x00, 0x22, 0x82, 0x82, 0x20, 0x66, 0x26, 0x88, + 0x62, 0x62, 0x66, 0x82, 0x62, 0x66, 0x66, 0x82, 0x62, 0x22, 0x62, 0x22, 0x20, 0xAA, 0x2A, 0x00, + 0x02, 0x10, 0x00, 0x00, 0x28, 0x71, 0x01, 0x00, 0x18, 0x77, 0x17, 0x00, 0x71, 0x77, 0x77, 0x01, + 0x11, 0x77, 0x17, 0x01, 0x12, 0x77, 0x17, 0x02, 0x12, 0x11, 0x11, 0x02, 0x20, 0xFF, 0x2F, 0x00, + 0xA2, 0xA2, 0xAA, 0x02, 0xA2, 0xAA, 0xAA, 0x22, 0xA2, 0xAA, 0x2A, 0x77, 0x20, 0xAA, 0x72, 0x72, + 0x00, 0x22, 0x72, 0x77, 0x00, 0x00, 0x72, 0x77, 0x00, 0x00, 0x20, 0x77, 0x00, 0x00, 0x00, 0x22, + 0xF2, 0xF2, 0xFF, 0x02, 0xF2, 0xFF, 0xFF, 0x02, 0x27, 0xFF, 0xFF, 0x02, 0x77, 0xF2, 0x2F, 0x00, + 0x77, 0x22, 0x02, 0x00, 0x77, 0x02, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, + # Archipelago Progression + Useful + 0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x20, 0x88, 0x00, 0x22, 0x82, 0x88, 0x20, 0x66, 0x26, 0x88, + 0x62, 0x66, 0x62, 0x82, 0x62, 0x66, 0x66, 0x82, 0x62, 0x22, 0x62, 0x22, 0x20, 0xAA, 0x2A, 0x00, + 0x02, 0x10, 0x00, 0x00, 0x28, 0x71, 0x01, 0x00, 0x12, 0x77, 0x17, 0x00, 0x71, 0x77, 0x77, 0x01, + 0x11, 0x77, 0x17, 0x01, 0x12, 0x77, 0x17, 0x02, 0x12, 0x11, 0x11, 0x02, 0x20, 0xFF, 0x2F, 0x00, + 0xA2, 0xAA, 0xA2, 0x02, 0xA2, 0xAA, 0xAA, 0x22, 0xA2, 0xAA, 0x2A, 0x77, 0x20, 0xAA, 0x72, 0x77, + 0x00, 0x22, 0x72, 0x77, 0x00, 0x00, 0x72, 0x77, 0x00, 0x00, 0x20, 0x77, 0x00, 0x00, 0x00, 0x22, + 0xF2, 0xFF, 0xF2, 0x02, 0xF2, 0xAA, 0xFA, 0x02, 0x27, 0x9A, 0xFA, 0x02, 0xAA, 0x9A, 0xAA, 0x0A, + 0x9A, 0x99, 0x99, 0x0A, 0xAA, 0x9A, 0xAA, 0x0A, 0x27, 0x9A, 0x0A, 0x00, 0x02, 0xAA, 0x0A, 0x00, + # Hourglass (Specifically used to represent Max Sand from Timespinner) + 0x00, 0x00, 0xFF, 0xFF, 0x00, 0xF0, 0xEE, 0xCC, 0x00, 0xF0, 0x43, 0x42, 0x00, 0xF0, 0x12, 0x11, + 0x00, 0x00, 0x1F, 0x11, 0x00, 0x00, 0x2F, 0x88, 0x00, 0x00, 0xF0, 0x82, 0x00, 0x00, 0x00, 0x1F, + 0xFF, 0xFF, 0x00, 0x00, 0xCC, 0xEE, 0x0F, 0x00, 0x42, 0x34, 0x0F, 0x00, 0x11, 0x21, 0x0F, 0x00, + 0x11, 0xF1, 0x00, 0x00, 0x98, 0xF2, 0x00, 0x00, 0x29, 0x0F, 0x00, 0x00, 0xF9, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF0, 0x81, 0x00, 0x00, 0x2F, 0x81, 0x00, 0x00, 0x1F, 0x88, + 0x00, 0xF0, 0x12, 0xA9, 0x00, 0xF0, 0x43, 0x24, 0x00, 0xF0, 0xEE, 0xCC, 0x00, 0x00, 0xFF, 0xFF, + 0xF9, 0x00, 0x00, 0x00, 0x19, 0x0F, 0x00, 0x00, 0x99, 0xF2, 0x00, 0x00, 0xA9, 0xF1, 0x00, 0x00, + 0xAA, 0x21, 0x0F, 0x00, 0x42, 0x34, 0x0F, 0x00, 0xCC, 0xEE, 0x0F, 0x00, 0xFF, 0xFF, 0x00, 0x00, +] diff --git a/worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md b/worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md new file mode 100644 index 000000000000..e81b79bf2048 --- /dev/null +++ b/worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md @@ -0,0 +1,169 @@ +# Castlevania: Circle of the Moon + +## Quick Links +- [Setup](/tutorial/Castlevania%20-%20Circle%20of%20the%20Moon/setup/en) +- [Options Page](/games/Castlevania%20-%20Circle%20of%20the%20Moon/player-options) +- [PopTracker Pack](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest) +- [Repo for the original, standalone CotMR](https://github.com/calm-palm/cotm-randomizer) +- [Web version of the above randomizer](https://rando.circleofthemoon.com/) +- [A more in-depth guide to CotMR's nuances](https://docs.google.com/document/d/1uot4BD9XW7A--A8ecgoY8mLK_vSoQRpY5XCkzgas87c/view?usp=sharing) + +This Game Page is focused more specifically on the Archipelago functionality. If you have a more general Circle of the Moon-related +question that is not answered here, try the above guide. + +## What does randomization do to this game? + +Almost all items that you would normally find on pedestals throughout the game have had their locations changed. In addition to +Magic Items (barring the Dash Boots which you always start with) and stat max ups, the DSS Cards have been added to the +item pool as well; you will now find these as randomized items rather than by farming them via enemy drops. + +## Can I use any of the alternate modes? + +Yes. All alternate modes (Magician, Fighter, Shooter, and Thief Mode) are all unlocked and usable from the start by registering +the name password shown on the Data Select screen for the mode of your choice. + +If you intend to play Magician Mode, putting all of your cards in "Start Inventory from Pool" is recommended due to the fact +that it naturally starts with all cards. In Fighter Mode, unlike in the regular game, you will be able to receive and use +DSS Cards like in all other modes. + +## What is the goal of Castlevania: Circle of the Moon when randomized? + +Depending on what was chosen for the "Completion Goal" option, your goal may be to defeat Dracula, complete the Battle Arena, or both. + +- "Dracula": Make it to the Ceremonial Room and kill Dracula's first and second forms to view the credits. The door to the +Ceremonial Room can be set to require anywhere between 0-9 Last Keys to open it. +- "Battle Arena": Survive every room in the Battle Arena and pick up the Shinning Armor sic on the pedestal at the end. To make it +easier, the "Disable Battle Arena Mp Drain" option can be enabled to make the Battle Arena not drain your MP to 0, allowing +DSS to be used. Reaching the Battle Arena in the first place requires finding the Heavy Ring and Roc Wing (as well as Double or Kick Boots +if "Nerf Roc Wing" is on). +- "Battle Arena And Dracula": Complete both of the above-mentioned objectives. The server will remember which ones (if any) were +already completed on previous sessions upon connecting. + +NOTE: If "All Bosses" was chosen for the "Required Skirmishes" option, 8 Last Keys will be required, and they will be guaranteed +to be placed behind all 8 bosses (that are not Dracula). If "All Bosses And Arena" was chosen for the option, an additional +required 9th Last Key will be placed on the Shinning Armor sic pedestal at the end of the Battle Arena in addition to +the 8 that will be behind all the bosses. + +If you aren't sure what goal you have, there are two in-game ways you can check: + +- Pause the game, go to the Magic Item menu, and view the Dash Boots tutorial. +- Approach the door to the first Battle Arena combat room and the textbox that normally explains how the place works will tell you. + +There are also two in-game ways to see how many Last Keys are in the item pool for the slot: + +- Pause the game, go to the Magic Item menu, and view the Last Key tutorial. +- If you don't have any keys, touch the Ceremonial Room door before acquiring the necessary amount. + + +## What items and locations get shuffled? + +Stat max ups, Magic Items, and DSS Cards are all randomized into the item pool, and the check locations are the pedestals +that you would normally find the first two types of items on. + +The sole exception is the pedestal at the end of the Battle Arena. This location, most of the time, will always have +Shinning Armor sic unless "Required Skirmishes" is set to "All Bosses And Arena", in which case it will have a Last Key instead. + +## Which items can be in another player's world? + +Stat max ups, Magic Items, and DSS Cards can all be placed into another player's world. + +The Dash Boots and Shinning Armor sic are not randomized in the item pool; the former you will always start with and the +latter will always be found at the end of the Battle Arena in your own world. And depending on your goal, you may or may +not be required to pick it up. + +## What does another world's item look like in Castlevania: Circle of the Moon? + +Items for other Circle of the Moon players will show up in your game as that item, though you won't receive it yourself upon +picking it up. Items for non-Circle of the Moon players will show up as one of four Archipelago Items depending on how its +classified: + +* "Filler": Just the six spheres, nothing extra. +* "Useful": Blue plus sign in the top-right corner. +* "Progression": Orange up arrow in the top-right corner. +* "Progression" and "Useful": Orange up arrow in the top-right corner, blue plus sign in the bottom-right corner. +* "Trap": Reports from the local residents of the remote Austrian village of \[REDACTED], Styria claim that they disguise themselves +as Progression but with the important difference of \[DATA EXPUNGED]. Verification of these claims are currently pending... + +Upon sending an item, a textbox announcing the item being sent and the player who it's for will show up on-screen, accompanied +by a sound depending on whether the item is filler-, progression-/useful-, or trap-classified. + +## When the player receives an item, what happens? + +A textbox announcing the item being received and the player who sent it will pop up on-screen, and it will be given. +Similar to the outgoing item textbox, it will be accompanied by a sound depending on the item received being filler or progression/useful. + +## What are the item name groups? + +When you attempt to hint for items in Archipelago you can use either the name for the specific item, or the name of a group +of items. Hinting for a group will choose a random item from the group that you do not currently have and hint for it. The +groups you can use for Castlevania: Circle of the Moon are as follows: + +* "DSS" or "Card": Any DSS Card of either type. +* "Action" or "Action Card": Any Action Card. +* "Attribute" or "Attribute Card": Any Attribute Card. +* "Freeze": Any card that logically lets you freeze enemies to use as platforms. +* "Action Freeze": Either Action Card that logically lets you freeze enemies. +* "Attribute Freeze": Either Attribute Card that logically lets you freeze enemies. + +## What are the location name groups? + +In Castlevania: Circle of the Moon, every location is part of a location group under that location's area name. +So if you want to exclude all of, say, Underground Waterway from having progression, you can do so by just excluding +"Underground Waterway" as a whole. + +In addition to the area location groups, the following groups also exist: + +* "Breakable Secrets": All locations behind the secret breakable walls, floors, and ceilings. +* "Bosses": All the primary locations behind bosses that Last Keys normally get forced onto when bosses are required. If you want +to prioritize every boss to be guarding a progression item for someone, this is the group for you! + +## How does the item drop randomization work? + +There are three tiers of item drops: Low, Mid, and High. Each enemy has two item "slots" that can both drop its own item; a Common slot and a Rare one. + +On Normal item randomization, "easy" enemies (below 61 HP) will only have Low-tier drops in both of their slots, bosses +and candle enemies will be guaranteed to have High drops in one or both of their slots respectively (bosses are made to +only drop one slot 100% of the time), and everything else can have a Low or Mid-tier item in its Common drop slot and a +Low, Mid, OR High-tier item in its Rare drop slot. + +If Item Drop Randomization is set to Tiered, the HP threshold for enemies being considered "easy" will raise to below +144, enemies in the 144-369 HP range (inclusive) will have a Low-tier item in its Common slot and a Mid-tier item in +its rare slot, and enemies with more than 369 HP will have a Mid-tier in its Common slot and a High-tier in its Rare +slot, making them more worthwhile to go after. Candles and bosses still have Rares in all their slots, but now the guaranteed +drops that land on bosses will be exclusive to them; no other enemy in the game will have their item. + +Note that the Shinning Armor sic can never be placed randomly onto a normal enemy; you can only receive it by completing the Battle Arena. +If "Required Skirmishes" is set to "All Bosses And Arena", which replaces the Shinning Armor sic on the pedestal at the end with +a Last Key, the Devil fought in the last room before the end pedestal will drop Shinning Armor sic 100% of the time upon defeat. + +For more information and an exact breakdown of what items are considered which tier, see Malaert64's guide +[here](https://docs.google.com/document/d/1uot4BD9XW7A--A8ecgoY8mLK_vSoQRpY5XCkzgas87c/view#heading=h.5iz6ytaji08m). + +## Is it just me, or does the Countdown seem inaccurate to the number of checks in the area? +Some Countdown regions are funny because of how the developers of the game decided what rooms belong to which areas in spite of +what most players might think. For instance, the Skeleton Athlete room is actually part of the Chapel Tower area, not the Audience Room. +And the Outer Wall very notably has several rooms isolated from its "main" area, like the Were-Horse/Jaguar Armory. +See [this map](https://docs.google.com/document/d/1uot4BD9XW7A--A8ecgoY8mLK_vSoQRpY5XCkzgas87c/view#heading=h.scu4u49kvcd4) +to know exactly which rooms make up which Countdown regions. + +## Will the Castlevania Advance Collection and/or Wii U Virtual Console versions work? + +The Castlevania Advance Collection ROM is tested and known to work. However, there are some major caveats when playing with the +Advance Collection ROM; most notably the fact that the audio does not function when played in an emulator outside the collection, +which is currently a requirement to connect to a multiworld. This happens because all audio code was stripped +from the ROM, and all sound is instead played by the collection through external means. + +For this reason, it is most recommended to obtain the ROM by dumping it from an original cartridge of the game that you legally own. +Though, the Advance Collection *can* still technically be an option if you cannot do that and don't mind the lack of sound. + +The Wii U Virtual Console version is currently untested. If you happen to have purchased it before the Wii U eShop shut down, you can try +dumping and playing with it. However, at the moment, we cannot guarantee that it will work well due to it being untested. + +Regardless of which released ROM you intend to try playing with, the US version of the game is required. + +## What are the odds of a pentabone? +The odds of skeleton Nathan throwing a big bone instead of a little one, verified by looking at the code itself, is 18, or 12.5%. + +Soooooooooo, to throw 5 big bones back-to-back... + +(18)5 = 132768, or 0.0030517578125%. Good luck, you're gonna need it! diff --git a/worlds/cvcotm/docs/setup_en.md b/worlds/cvcotm/docs/setup_en.md new file mode 100644 index 000000000000..7899ac997366 --- /dev/null +++ b/worlds/cvcotm/docs/setup_en.md @@ -0,0 +1,72 @@ +# Castlevania: Circle of the Moon Setup Guide + +## Required Software + +- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest). +- A Castlevania: Circle of the Moon ROM of the US version specifically. The Archipelago community cannot provide this. +The Castlevania Advance Collection ROM can technically be used, but it has no audio. The Wii U Virtual Console ROM is untested. +- [BizHawk](https://tasvideos.org/BizHawk/ReleaseHistory) 2.7 or later. + +### Configuring BizHawk + +Once you have installed BizHawk, open `EmuHawk.exe` and change the following settings: + +- If you're using BizHawk 2.7 or 2.8, go to `Config > Customize`. On the Advanced tab, switch the Lua Core from +`NLua+KopiLua` to `Lua+LuaInterface`, then restart EmuHawk. (If you're using BizHawk 2.9, you can skip this step.) +- Under `Config > Customize`, check the "Run in background" option to prevent disconnecting from the client while you're +tabbed out of EmuHawk. +- Open a `.gba` file in EmuHawk and go to `Config > Controllers…` to configure your inputs. If you can't click +`Controllers…`, load any `.gba` ROM first. +- Consider clearing keybinds in `Config > Hotkeys…` if you don't intend to use them. Select the keybind and press Esc to +clear it. + +## Optional Software + +- [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest), for use with +[PopTracker](https://github.com/black-sliver/PopTracker/releases). + +## Generating and Patching a Game + +1. Create your settings file (YAML). You can make one on the [Castlevania: Circle of the Moon options page](../../../games/Castlevania%20-%20Circle%20of%20the%20Moon/player-options). +2. Follow the general Archipelago instructions for [generating a game](../../Archipelago/setup/en#generating-a-game). +This will generate an output file for you. Your patch file will have the `.apcvcotm` file extension. +3. Open `ArchipelagoLauncher.exe`. +4. Select "Open Patch" on the left side and select your patch file. +5. If this is your first time patching, you will be prompted to locate your vanilla ROM. +6. A patched `.gba` file will be created in the same place as the patch file. +7. On your first time opening a patch with BizHawk Client, you will also be asked to locate `EmuHawk.exe` in your +BizHawk install. + +If you're playing a single-player seed, and you don't care about hints, you can stop here, close the client, and load +the patched ROM in any emulator of your choice. However, for multiworlds and other Archipelago features, +continue below using BizHawk as your emulator. + +## Connecting to a Server + +By default, opening a patch file will do steps 1-5 below for you automatically. Even so, keep them in your memory just +in case you have to close and reopen a window mid-game for some reason. + +1. Castlevania: Circle of the Moon uses Archipelago's BizHawk Client. If the client isn't still open from when you patched your game, +you can re-open it from the launcher. +2. Ensure EmuHawk is running the patched ROM. +3. In EmuHawk, go to `Tools > Lua Console`. This window must stay open while playing. +4. In the Lua Console window, go to `Script > Open Script…`. +5. Navigate to your Archipelago install folder and open `data/lua/connector_bizhawk_generic.lua`. +6. The emulator may freeze every few seconds until it manages to connect to the client. This is expected. The BizHawk +Client window should indicate that it connected and recognized Castlevania: Circle of the Moon. +7. To connect the client to the server, enter your room's address and port (e.g. `archipelago.gg:38281`) into the +top text field of the client and click Connect. + +You should now be able to receive and send items. You'll need to do these steps every time you want to reconnect. It is +perfectly safe to make progress offline; everything will re-sync when you reconnect. + +## Auto-Tracking + +Castlevania: Circle of the Moon has a fully functional map tracker that supports auto-tracking. + +1. Download [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest) and +[PopTracker](https://github.com/black-sliver/PopTracker/releases). +2. Put the tracker pack into `packs/` in your PopTracker install. +3. Open PopTracker, and load the Castlevania: Circle of the Moon pack. +4. For autotracking, click on the "AP" symbol at the top. +5. Enter the Archipelago server address (the one you connected your client to), slot name, and password. diff --git a/worlds/cvcotm/items.py b/worlds/cvcotm/items.py new file mode 100644 index 000000000000..bce2b3fc0c4d --- /dev/null +++ b/worlds/cvcotm/items.py @@ -0,0 +1,211 @@ +import logging + +from BaseClasses import Item, ItemClassification +from .data import iname +from .locations import BASE_ID +from .options import IronMaidenBehavior + +from typing import TYPE_CHECKING, Dict, NamedTuple, Optional +from collections import Counter + +if TYPE_CHECKING: + from . import CVCotMWorld + + +class CVCotMItem(Item): + game: str = "Castlevania - Circle of the Moon" + + +class CVCotMItemData(NamedTuple): + code: Optional[int] + text_id: Optional[bytes] + default_classification: ItemClassification + tutorial_id: Optional[bytes] = None +# "code" = The unique part of the Item's AP code attribute, as well as the value to call the in-game "prepare item +# textbox" function with to give the Item in-game. Add this + base_id to get the actual AP code. +# "text_id" = The textbox ID for the vanilla message for receiving the Item. Used when receiving an Item through the +# client that was not sent by a different player. +# "default_classification" = The AP Item Classification that gets assigned to instances of that Item in create_item +# by default, unless I deliberately override it (as is the case for the Cleansing on the +# Ignore Cleansing option). +# "tutorial_id" = The textbox ID for the item's tutorial. Used by the client if tutorials are not skipped. + + +cvcotm_item_info: Dict[str, CVCotMItemData] = { + iname.heart_max: CVCotMItemData(0xE400, b"\x57\x81", ItemClassification.filler), + iname.hp_max: CVCotMItemData(0xE401, b"\x55\x81", ItemClassification.filler), + iname.mp_max: CVCotMItemData(0xE402, b"\x56\x81", ItemClassification.filler), + iname.salamander: CVCotMItemData(0xE600, b"\x1E\x82", ItemClassification.useful), + iname.serpent: CVCotMItemData(0xE601, b"\x1F\x82", ItemClassification.useful | + ItemClassification.progression), + iname.mandragora: CVCotMItemData(0xE602, b"\x20\x82", ItemClassification.useful), + iname.golem: CVCotMItemData(0xE603, b"\x21\x82", ItemClassification.useful), + iname.cockatrice: CVCotMItemData(0xE604, b"\x22\x82", ItemClassification.useful | + ItemClassification.progression), + iname.manticore: CVCotMItemData(0xE605, b"\x23\x82", ItemClassification.useful), + iname.griffin: CVCotMItemData(0xE606, b"\x24\x82", ItemClassification.useful), + iname.thunderbird: CVCotMItemData(0xE607, b"\x25\x82", ItemClassification.useful), + iname.unicorn: CVCotMItemData(0xE608, b"\x26\x82", ItemClassification.useful), + iname.black_dog: CVCotMItemData(0xE609, b"\x27\x82", ItemClassification.useful), + iname.mercury: CVCotMItemData(0xE60A, b"\x28\x82", ItemClassification.useful | + ItemClassification.progression), + iname.venus: CVCotMItemData(0xE60B, b"\x29\x82", ItemClassification.useful), + iname.jupiter: CVCotMItemData(0xE60C, b"\x2A\x82", ItemClassification.useful), + iname.mars: CVCotMItemData(0xE60D, b"\x2B\x82", ItemClassification.useful | + ItemClassification.progression), + iname.diana: CVCotMItemData(0xE60E, b"\x2C\x82", ItemClassification.useful), + iname.apollo: CVCotMItemData(0xE60F, b"\x2D\x82", ItemClassification.useful), + iname.neptune: CVCotMItemData(0xE610, b"\x2E\x82", ItemClassification.useful), + iname.saturn: CVCotMItemData(0xE611, b"\x2F\x82", ItemClassification.useful), + iname.uranus: CVCotMItemData(0xE612, b"\x30\x82", ItemClassification.useful), + iname.pluto: CVCotMItemData(0xE613, b"\x31\x82", ItemClassification.useful), + # Dash Boots + iname.double: CVCotMItemData(0xE801, b"\x59\x81", ItemClassification.useful | + ItemClassification.progression, b"\xF4\x84"), + iname.tackle: CVCotMItemData(0xE802, b"\x5A\x81", ItemClassification.progression, b"\xF5\x84"), + iname.kick_boots: CVCotMItemData(0xE803, b"\x5B\x81", ItemClassification.progression, b"\xF6\x84"), + iname.heavy_ring: CVCotMItemData(0xE804, b"\x5C\x81", ItemClassification.progression, b"\xF7\x84"), + # Map + iname.cleansing: CVCotMItemData(0xE806, b"\x5D\x81", ItemClassification.progression, b"\xF8\x84"), + iname.roc_wing: CVCotMItemData(0xE807, b"\x5E\x81", ItemClassification.useful | + ItemClassification.progression, b"\xF9\x84"), + iname.last_key: CVCotMItemData(0xE808, b"\x5F\x81", ItemClassification.progression_skip_balancing, + b"\xFA\x84"), + iname.ironmaidens: CVCotMItemData(0xE809, b"\xF1\x84", ItemClassification.progression), + iname.dracula: CVCotMItemData(None, None, ItemClassification.progression), + iname.shinning_armor: CVCotMItemData(None, None, ItemClassification.progression), +} + +ACTION_CARDS = {iname.mercury, iname.venus, iname.jupiter, iname.mars, iname.diana, iname.apollo, iname.neptune, + iname.saturn, iname.uranus, iname.pluto} + +ATTRIBUTE_CARDS = {iname.salamander, iname.serpent, iname.mandragora, iname.golem, iname.cockatrice, iname.griffin, + iname.manticore, iname.thunderbird, iname.unicorn, iname.black_dog} + +FREEZE_ACTIONS = [iname.mercury, iname.mars] +FREEZE_ATTRS = [iname.serpent, iname.cockatrice] + +FILLER_ITEM_NAMES = [iname.heart_max, iname.hp_max, iname.mp_max] + +MAJORS_CLASSIFICATIONS = ItemClassification.progression | ItemClassification.useful + + +def get_item_names_to_ids() -> Dict[str, int]: + return {name: cvcotm_item_info[name].code + BASE_ID for name in cvcotm_item_info + if cvcotm_item_info[name].code is not None} + + +def get_item_counts(world: "CVCotMWorld") -> Dict[ItemClassification, Dict[str, int]]: + + item_counts: Dict[ItemClassification, Counter[str, int]] = { + ItemClassification.progression: Counter(), + ItemClassification.progression_skip_balancing: Counter(), + ItemClassification.useful | ItemClassification.progression: Counter(), + ItemClassification.useful: Counter(), + ItemClassification.filler: Counter(), + } + total_items = 0 + # Items to be skipped over in the main Item creation loop. + excluded_items = [iname.hp_max, iname.mp_max, iname.heart_max, iname.last_key] + + # If Halve DSS Cards Placed is on, determine which cards we will exclude here. + if world.options.halve_dss_cards_placed: + excluded_cards = list(ACTION_CARDS.union(ATTRIBUTE_CARDS)) + + has_freeze_action = False + has_freeze_attr = False + start_card_cap = 8 + + # Get out all cards from start_inventory_from_pool that the player isn't starting with 0 of. + start_cards = [item for item in world.options.start_inventory_from_pool.value if "Card" in item] + + # Check for ice/stone cards that are in the player's starting cards. Increase the starting card capacity by 1 + # for each card type satisfied. + for card in start_cards: + if card in FREEZE_ACTIONS and not has_freeze_action: + has_freeze_action = True + start_card_cap += 1 + if card in FREEZE_ATTRS and not has_freeze_attr: + has_freeze_attr = True + start_card_cap += 1 + + # If we are over our starting card capacity, some starting cards will need to be removed... + if len(start_cards) > start_card_cap: + + # Ice/stone cards will be kept no matter what. As for the others, put them in a list of possible candidates + # to remove. + kept_start_cards = [] + removal_candidates = [] + for card in start_cards: + if card in FREEZE_ACTIONS + FREEZE_ATTRS: + kept_start_cards.append(card) + else: + removal_candidates.append(card) + + # Add a random sample of the removal candidate cards to our kept cards list. + kept_start_cards += world.random.sample(removal_candidates, start_card_cap - len(kept_start_cards)) + + # Make a list of the cards we are not keeping. + removed_start_cards = [card for card in removal_candidates if card not in kept_start_cards] + + # Remove the cards we're not keeping from start_inventory_from_pool. + for card in removed_start_cards: + del world.options.start_inventory_from_pool.value[card] + + logging.warning(f"[{world.player_name}] Too many DSS Cards in " + f"Start Inventory from Pool to satisfy the Halve DSS Cards Placed option. The following " + f"{len(removed_start_cards)} card(s) were removed: {removed_start_cards}") + + start_cards = kept_start_cards + + # Remove the starting cards from the excluded cards. + for card in ACTION_CARDS.union(ATTRIBUTE_CARDS): + if card in start_cards: + excluded_cards.remove(card) + + # Remove a valid ice/stone action and/or attribute card if the player isn't starting with one. + if not has_freeze_action: + excluded_cards.remove(world.random.choice(FREEZE_ACTIONS)) + if not has_freeze_attr: + excluded_cards.remove(world.random.choice(FREEZE_ATTRS)) + + # Remove 10 random cards from the exclusions. + excluded_items += world.random.sample(excluded_cards, 10) + + # Exclude the Maiden Detonator from creation if the maidens start broken. + if world.options.iron_maiden_behavior == IronMaidenBehavior.option_start_broken: + excluded_items += [iname.ironmaidens] + + # Add one of each Item to the pool that is not filler or progression skip balancing. + for item in cvcotm_item_info: + classification = cvcotm_item_info[item].default_classification + code = cvcotm_item_info[item].code + + # Skip event Items and Items that are excluded from creation. + if code is None or item in excluded_items: + continue + + # Classify the Cleansing as Useful instead of Progression if Ignore Cleansing is on. + if item == iname.cleansing and world.options.ignore_cleansing: + classification = ItemClassification.useful + + # Classify the Kick Boots as Progression + Useful if Nerf Roc Wing is on. + if item == iname.kick_boots and world.options.nerf_roc_wing: + classification |= ItemClassification.useful + + item_counts[classification][item] = 1 + total_items += 1 + + # Add the total Last Keys if no skirmishes are required (meaning they're not forced anywhere). + if not world.options.required_skirmishes: + item_counts[ItemClassification.progression_skip_balancing][iname.last_key] = \ + world.options.available_last_keys.value + total_items += world.options.available_last_keys.value + + # Add filler items at random until the total Items = the total Locations. + while total_items < len(world.multiworld.get_unfilled_locations(world.player)): + filler_to_add = world.random.choice(FILLER_ITEM_NAMES) + item_counts[ItemClassification.filler][filler_to_add] += 1 + total_items += 1 + + return item_counts diff --git a/worlds/cvcotm/locations.py b/worlds/cvcotm/locations.py new file mode 100644 index 000000000000..02f1e65ab6f8 --- /dev/null +++ b/worlds/cvcotm/locations.py @@ -0,0 +1,265 @@ +from BaseClasses import Location +from .data import lname, iname +from .options import CVCotMOptions, CompletionGoal, IronMaidenBehavior, RequiredSkirmishes + +from typing import Dict, List, Union, Tuple, Optional, Set, NamedTuple + +BASE_ID = 0xD55C0000 + + +class CVCotMLocation(Location): + game: str = "Castlevania - Circle of the Moon" + + +class CVCotMLocationData(NamedTuple): + code: Union[int, str] + offset: Optional[int] + countdown: Optional[int] + type: Optional[str] = None +# code = The unique part of the Location's AP code attribute, as well as the in-game bitflag index starting from +# 0x02025374 that indicates the Location has been checked. Add this + base_id to get the actual AP code. +# If we put an Item name string here instead of an int, then it is an event Location and that Item should be +# forced on it while calling the actual code None. +# offset = The offset in the ROM to overwrite to change the Item on that Location. +# countdown = The index of the Countdown number region it contributes to. +# rule = What rule should be applied to the Location during set_rules, as defined in self.rules in the CVCotMRules class +# definition in rules.py. +# event = What event Item to place on that Location, for Locations that are events specifically. +# type = Anything special about this Location that should be considered, whether it be a boss Location, etc. + + +cvcotm_location_info: Dict[str, CVCotMLocationData] = { + # Sealed Room + lname.sr3: CVCotMLocationData(0x35, 0xD0310, 0), + # Catacombs + lname.cc1: CVCotMLocationData(0x37, 0xD0658, 1), + lname.cc3: CVCotMLocationData(0x43, 0xD0370, 1), + lname.cc3b: CVCotMLocationData(0x36, 0xD0364, 1), + lname.cc4: CVCotMLocationData(0xA8, 0xD0934, 1, type="magic item"), + lname.cc5: CVCotMLocationData(0x38, 0xD0DE4, 1), + lname.cc8: CVCotMLocationData(0x3A, 0xD1078, 1), + lname.cc8b: CVCotMLocationData(0x3B, 0xD1084, 1), + lname.cc9: CVCotMLocationData(0x40, 0xD0F94, 1), + lname.cc10: CVCotMLocationData(0x39, 0xD12C4, 1), + lname.cc13: CVCotMLocationData(0x41, 0xD0DA8, 1), + lname.cc14: CVCotMLocationData(0x3C, 0xD1168, 1), + lname.cc14b: CVCotMLocationData(0x3D, 0xD1174, 1), + lname.cc16: CVCotMLocationData(0x3E, 0xD0C40, 1), + lname.cc20: CVCotMLocationData(0x42, 0xD103C, 1), + lname.cc22: CVCotMLocationData(0x3F, 0xD07C0, 1), + lname.cc24: CVCotMLocationData(0xA9, 0xD1288, 1, type="boss"), + lname.cc25: CVCotMLocationData(0x44, 0xD12A0, 1), + # Abyss Staircase + lname.as2: CVCotMLocationData(0x47, 0xD181C, 2), + lname.as3: CVCotMLocationData(0x45, 0xD1774, 2), + lname.as4: CVCotMLocationData(0x46, 0xD1678, 2), + lname.as9: CVCotMLocationData(0x48, 0xD17EC, 2), + # Audience Room + lname.ar4: CVCotMLocationData(0x53, 0xD2344, 3), + lname.ar7: CVCotMLocationData(0x54, 0xD2368, 3), + lname.ar8: CVCotMLocationData(0x51, 0xD1BF4, 3), + lname.ar9: CVCotMLocationData(0x4B, 0xD1E1C, 3), + lname.ar10: CVCotMLocationData(0x4A, 0xD1DE0, 3), + lname.ar11: CVCotMLocationData(0x49, 0xD1E58, 3), + lname.ar14: CVCotMLocationData(0x4D, 0xD2158, 3), + lname.ar14b: CVCotMLocationData(0x4C, 0xD214C, 3), + lname.ar16: CVCotMLocationData(0x52, 0xD20BC, 3), + lname.ar17: CVCotMLocationData(0x50, 0xD2290, 3), + lname.ar17b: CVCotMLocationData(0x4F, 0xD2284, 3), + lname.ar18: CVCotMLocationData(0x4E, 0xD1FA8, 3), + lname.ar19: CVCotMLocationData(0x6A, 0xD44A4, 7), + lname.ar21: CVCotMLocationData(0x55, 0xD238C, 3), + lname.ar25: CVCotMLocationData(0xAA, 0xD1E04, 3, type="boss"), + lname.ar26: CVCotMLocationData(0x59, 0xD3370, 5), + lname.ar27: CVCotMLocationData(0x58, 0xD34E4, 5), + lname.ar30: CVCotMLocationData(0x99, 0xD6A24, 11), + lname.ar30b: CVCotMLocationData(0x9A, 0xD6A30, 11), + # Outer Wall + lname.ow0: CVCotMLocationData(0x97, 0xD6BEC, 11), + lname.ow1: CVCotMLocationData(0x98, 0xD6CE8, 11), + lname.ow2: CVCotMLocationData(0x9E, 0xD6DE4, 11), + # Triumph Hallway + lname.th1: CVCotMLocationData(0x57, 0xD26D4, 4), + lname.th3: CVCotMLocationData(0x56, 0xD23C8, 4), + # Machine Tower + lname.mt0: CVCotMLocationData(0x61, 0xD307C, 5), + lname.mt2: CVCotMLocationData(0x62, 0xD32A4, 5), + lname.mt3: CVCotMLocationData(0x5B, 0xD3244, 5), + lname.mt4: CVCotMLocationData(0x5A, 0xD31FC, 5), + lname.mt6: CVCotMLocationData(0x5F, 0xD2F38, 5), + lname.mt8: CVCotMLocationData(0x5E, 0xD2EC0, 5), + lname.mt10: CVCotMLocationData(0x63, 0xD3550, 5), + lname.mt11: CVCotMLocationData(0x5D, 0xD2D88, 5), + lname.mt13: CVCotMLocationData(0x5C, 0xD3580, 5), + lname.mt14: CVCotMLocationData(0x60, 0xD2A64, 5), + lname.mt17: CVCotMLocationData(0x64, 0xD3520, 5), + lname.mt19: CVCotMLocationData(0xAB, 0xD283C, 5, type="boss"), + # Eternal Corridor + lname.ec5: CVCotMLocationData(0x66, 0xD3B50, 6), + lname.ec7: CVCotMLocationData(0x65, 0xD3A90, 6), + lname.ec9: CVCotMLocationData(0x67, 0xD3B98, 6), + # Chapel Tower + lname.ct1: CVCotMLocationData(0x68, 0xD40F0, 7), + lname.ct4: CVCotMLocationData(0x69, 0xD4630, 7), + lname.ct5: CVCotMLocationData(0x72, 0xD481C, 7), + lname.ct6: CVCotMLocationData(0x6B, 0xD4294, 7), + lname.ct6b: CVCotMLocationData(0x6C, 0xD42A0, 7), + lname.ct8: CVCotMLocationData(0x6D, 0xD4330, 7), + lname.ct10: CVCotMLocationData(0x6E, 0xD415C, 7), + lname.ct13: CVCotMLocationData(0x6F, 0xD4060, 7), + lname.ct15: CVCotMLocationData(0x73, 0xD47F8, 7), + lname.ct16: CVCotMLocationData(0x70, 0xD3DA8, 7), + lname.ct18: CVCotMLocationData(0x74, 0xD47C8, 7), + lname.ct21: CVCotMLocationData(0xF0, 0xD47B0, 7, type="maiden switch"), + lname.ct22: CVCotMLocationData(0x71, 0xD3CF4, 7, type="max up boss"), + lname.ct26: CVCotMLocationData(0x9C, 0xD6ACC, 11), + lname.ct26b: CVCotMLocationData(0x9B, 0xD6AC0, 11), + # Underground Gallery + lname.ug0: CVCotMLocationData(0x82, 0xD5944, 9), + lname.ug1: CVCotMLocationData(0x83, 0xD5890, 9), + lname.ug2: CVCotMLocationData(0x81, 0xD5A1C, 9), + lname.ug3: CVCotMLocationData(0x85, 0xD56A4, 9), + lname.ug3b: CVCotMLocationData(0x84, 0xD5698, 9), + lname.ug8: CVCotMLocationData(0x86, 0xD5E30, 9), + lname.ug10: CVCotMLocationData(0x87, 0xD5F68, 9), + lname.ug13: CVCotMLocationData(0x88, 0xD5AB8, 9), + lname.ug15: CVCotMLocationData(0x89, 0xD5BD8, 9), + lname.ug20: CVCotMLocationData(0xAC, 0xD5470, 9, type="boss"), + # Underground Warehouse + lname.uw1: CVCotMLocationData(0x75, 0xD48DC, 8), + lname.uw6: CVCotMLocationData(0x76, 0xD4D20, 8), + lname.uw8: CVCotMLocationData(0x77, 0xD4BA0, 8), + lname.uw9: CVCotMLocationData(0x7E, 0xD53EC, 8), + lname.uw10: CVCotMLocationData(0x78, 0xD4C84, 8), + lname.uw11: CVCotMLocationData(0x79, 0xD4EC4, 8), + lname.uw14: CVCotMLocationData(0x7F, 0xD5410, 8), + lname.uw16: CVCotMLocationData(0x7A, 0xD5050, 8), + lname.uw16b: CVCotMLocationData(0x7B, 0xD505C, 8), + lname.uw19: CVCotMLocationData(0x7C, 0xD5344, 8), + lname.uw23: CVCotMLocationData(0xAE, 0xD53B0, 8, type="boss"), + lname.uw24: CVCotMLocationData(0x80, 0xD5434, 8), + lname.uw25: CVCotMLocationData(0x7D, 0xD4FC0, 8), + # Underground Waterway + lname.uy1: CVCotMLocationData(0x93, 0xD5F98, 10), + lname.uy3: CVCotMLocationData(0x8B, 0xD5FEC, 10), + lname.uy3b: CVCotMLocationData(0x8A, 0xD5FE0, 10), + lname.uy4: CVCotMLocationData(0x94, 0xD697C, 10), + lname.uy5: CVCotMLocationData(0x8C, 0xD6214, 10), + lname.uy7: CVCotMLocationData(0x8D, 0xD65A4, 10), + lname.uy8: CVCotMLocationData(0x95, 0xD69A0, 10), + lname.uy9: CVCotMLocationData(0x8E, 0xD640C, 10), + lname.uy9b: CVCotMLocationData(0x8F, 0xD6418, 10), + lname.uy12: CVCotMLocationData(0x90, 0xD6730, 10), + lname.uy12b: CVCotMLocationData(0x91, 0xD673C, 10), + lname.uy13: CVCotMLocationData(0x92, 0xD685C, 10), + lname.uy17: CVCotMLocationData(0xAF, 0xD6940, 10, type="boss"), + lname.uy18: CVCotMLocationData(0x96, 0xD69C4, 10), + # Observation Tower + lname.ot1: CVCotMLocationData(0x9D, 0xD6B38, 11), + lname.ot2: CVCotMLocationData(0xA4, 0xD760C, 12), + lname.ot3: CVCotMLocationData(0x9F, 0xD72E8, 12), + lname.ot5: CVCotMLocationData(0xA5, 0xD75E8, 12), + lname.ot8: CVCotMLocationData(0xA0, 0xD71EC, 12), + lname.ot9: CVCotMLocationData(0xA2, 0xD6FE8, 12), + lname.ot12: CVCotMLocationData(0xA6, 0xD75C4, 12), + lname.ot13: CVCotMLocationData(0xA3, 0xD6F64, 12), + lname.ot16: CVCotMLocationData(0xA1, 0xD751C, 12), + lname.ot20: CVCotMLocationData(0xB0, 0xD6E20, 12, type="boss"), + # Ceremonial Room + lname.cr1: CVCotMLocationData(0xA7, 0xD7690, 13), + lname.dracula: CVCotMLocationData(iname.dracula, None, None), + # Battle Arena + lname.ba24: CVCotMLocationData(0xB2, 0xD7D20, 14, type="arena"), + lname.arena_victory: CVCotMLocationData(iname.shinning_armor, None, None), + } + + +def get_location_names_to_ids() -> Dict[str, int]: + return {name: cvcotm_location_info[name].code+BASE_ID for name in cvcotm_location_info + if isinstance(cvcotm_location_info[name].code, int)} + + +def get_location_name_groups() -> Dict[str, Set[str]]: + loc_name_groups: Dict[str, Set[str]] = {"Breakable Secrets": set(), + "Bosses": set()} + + for loc_name in cvcotm_location_info: + # If we are looking at an event Location, don't include it. + if isinstance(cvcotm_location_info[loc_name].code, str): + continue + + # The part of the Location name's string before the colon is its area name. + area_name = loc_name.split(":")[0] + + # Add each Location to its corresponding area name group. + if area_name not in loc_name_groups: + loc_name_groups[area_name] = {loc_name} + else: + loc_name_groups[area_name].add(loc_name) + + # If the word "fake" is in the Location's name, add it to the "Breakable Walls" Location group. + if "fake" in loc_name.casefold(): + loc_name_groups["Breakable Secrets"].add(loc_name) + + # If it's a behind boss Location, add it to the "Bosses" Location group. + if cvcotm_location_info[loc_name].type in ["boss", "max up boss"]: + loc_name_groups["Bosses"].add(loc_name) + + return loc_name_groups + + +def get_named_locations_data(locations: List[str], options: CVCotMOptions) -> \ + Tuple[Dict[str, Optional[int]], Dict[str, str]]: + locations_with_ids = {} + locked_pairs = {} + locked_key_types = [] + + # Decide which Location types should have locked Last Keys placed on them, if skirmishes are required. + # If the Maiden Detonator is in the pool, Adramelech's key should be on the switch instead of behind the maiden. + if options.required_skirmishes: + locked_key_types += ["boss"] + if options.iron_maiden_behavior == IronMaidenBehavior.option_detonator_in_pool: + locked_key_types += ["maiden switch"] + else: + locked_key_types += ["max up boss"] + # If all bosses and the Arena is required, the Arena end reward should have a Last Key as well. + if options.required_skirmishes == RequiredSkirmishes.option_all_bosses_and_arena: + locked_key_types += ["arena"] + + for loc in locations: + if loc == lname.ct21: + # If the maidens are pre-broken, don't create the iron maiden switch Location at all. + if options.iron_maiden_behavior == IronMaidenBehavior.option_start_broken: + continue + # If the maiden behavior is vanilla, lock the Maiden Detonator on this Location. + if options.iron_maiden_behavior == IronMaidenBehavior.option_vanilla: + locked_pairs[loc] = iname.ironmaidens + + # Don't place the Dracula Location if our Completion Goal is the Battle Arena only. + if loc == lname.dracula and options.completion_goal == CompletionGoal.option_battle_arena: + continue + + # Don't place the Battle Arena normal Location if the Arena is not required by the Skirmishes option. + if loc == lname.ba24 and options.required_skirmishes != RequiredSkirmishes.option_all_bosses_and_arena: + continue + + # Don't place the Battle Arena event Location if our Completion Goal is Dracula only. + if loc == lname.arena_victory and options.completion_goal == CompletionGoal.option_dracula: + continue + + loc_code = cvcotm_location_info[loc].code + + # If we are looking at an event Location, add its associated event Item to the events' dict. + # Otherwise, add the base_id to the Location's code. + if isinstance(loc_code, str): + locked_pairs[loc] = loc_code + locations_with_ids.update({loc: None}) + else: + loc_code += BASE_ID + locations_with_ids.update({loc: loc_code}) + + # Place a locked Last Key on this Location if its of a type that should have one. + if cvcotm_location_info[loc].type in locked_key_types: + locked_pairs[loc] = iname.last_key + + return locations_with_ids, locked_pairs diff --git a/worlds/cvcotm/lz10.py b/worlds/cvcotm/lz10.py new file mode 100644 index 000000000000..5ca24c13dd17 --- /dev/null +++ b/worlds/cvcotm/lz10.py @@ -0,0 +1,265 @@ +from collections import defaultdict +from operator import itemgetter +import struct +from typing import Union + +ByteString = Union[bytes, bytearray, memoryview] + + +""" +Taken from the Archipelago Metroid: Zero Mission implementation by Lil David at: +https://github.com/lilDavid/Archipelago-Metroid-Zero-Mission/blob/main/lz10.py + +Tweaked version of nlzss modified to work with raw data and return bytes instead of operating on whole files. +LZ11 functionality has been removed since it is not necessary for Zero Mission nor Circle of the Moon. + +https://github.com/magical/nlzss +""" + + +def decompress(data: ByteString): + """Decompress LZSS-compressed bytes. Returns a bytearray containing the decompressed data.""" + header = data[:4] + if header[0] == 0x10: + decompress_raw = decompress_raw_lzss10 + else: + raise DecompressionError("not as lzss-compressed file") + + decompressed_size = int.from_bytes(header[1:], "little") + + data = data[4:] + return decompress_raw(data, decompressed_size) + + +def compress(data: bytearray): + byteOut = bytearray() + # header + byteOut.extend(struct.pack("B", packflags(flags))) + + for t in tokens: + if type(t) is tuple: + count, disp = t + count -= 3 + disp = (-disp) - 1 + assert 0 <= disp < 4096 + sh = (count << 12) | disp + byteOut.extend(struct.pack(">H", sh)) + else: + byteOut.extend(struct.pack(">B", t)) + + length += 1 + length += sum(2 if f else 1 for f in flags) + + # padding + padding = 4 - (length % 4 or 4) + if padding: + byteOut.extend(b'\xff' * padding) + return byteOut + + +class SlidingWindow: + # The size of the sliding window + size = 4096 + + # The minimum displacement. + disp_min = 2 + + # The hard minimum — a disp less than this can't be represented in the + # compressed stream. + disp_start = 1 + + # The minimum length for a successful match in the window + match_min = 3 + + # The maximum length of a successful match, inclusive. + match_max = 3 + 0xf + + def __init__(self, buf): + self.data = buf + self.hash = defaultdict(list) + self.full = False + + self.start = 0 + self.stop = 0 + # self.index = self.disp_min - 1 + self.index = 0 + + assert self.match_max is not None + + def next(self): + if self.index < self.disp_start - 1: + self.index += 1 + return + + if self.full: + olditem = self.data[self.start] + assert self.hash[olditem][0] == self.start + self.hash[olditem].pop(0) + + item = self.data[self.stop] + self.hash[item].append(self.stop) + self.stop += 1 + self.index += 1 + + if self.full: + self.start += 1 + else: + if self.size <= self.stop: + self.full = True + + def advance(self, n=1): + """Advance the window by n bytes""" + for _ in range(n): + self.next() + + def search(self): + match_max = self.match_max + match_min = self.match_min + + counts = [] + indices = self.hash[self.data[self.index]] + for i in indices: + matchlen = self.match(i, self.index) + if matchlen >= match_min: + disp = self.index - i + if self.disp_min <= disp: + counts.append((matchlen, -disp)) + if matchlen >= match_max: + return counts[-1] + + if counts: + match = max(counts, key=itemgetter(0)) + return match + + return None + + def match(self, start, bufstart): + size = self.index - start + + if size == 0: + return 0 + + matchlen = 0 + it = range(min(len(self.data) - bufstart, self.match_max)) + for i in it: + if self.data[start + (i % size)] == self.data[bufstart + i]: + matchlen += 1 + else: + break + return matchlen + + +def _compress(input, windowclass=SlidingWindow): + """Generates a stream of tokens. Either a byte (int) or a tuple of (count, + displacement).""" + + window = windowclass(input) + + i = 0 + while True: + if len(input) <= i: + break + match = window.search() + if match: + yield match + window.advance(match[0]) + i += match[0] + else: + yield input[i] + window.next() + i += 1 + + +def packflags(flags): + n = 0 + for i in range(8): + n <<= 1 + try: + if flags[i]: + n |= 1 + except IndexError: + pass + return n + + +def chunkit(it, n): + buf = [] + for x in it: + buf.append(x) + if n <= len(buf): + yield buf + buf = [] + if buf: + yield buf + + +def bits(byte): + return ((byte >> 7) & 1, + (byte >> 6) & 1, + (byte >> 5) & 1, + (byte >> 4) & 1, + (byte >> 3) & 1, + (byte >> 2) & 1, + (byte >> 1) & 1, + byte & 1) + + +def decompress_raw_lzss10(indata, decompressed_size, _overlay=False): + """Decompress LZSS-compressed bytes. Returns a bytearray.""" + data = bytearray() + + it = iter(indata) + + if _overlay: + disp_extra = 3 + else: + disp_extra = 1 + + def writebyte(b): + data.append(b) + + def readbyte(): + return next(it) + + def readshort(): + # big-endian + a = next(it) + b = next(it) + return (a << 8) | b + + def copybyte(): + data.append(next(it)) + + while len(data) < decompressed_size: + b = readbyte() + flags = bits(b) + for flag in flags: + if flag == 0: + copybyte() + elif flag == 1: + sh = readshort() + count = (sh >> 0xc) + 3 + disp = (sh & 0xfff) + disp_extra + + for _ in range(count): + writebyte(data[-disp]) + else: + raise ValueError(flag) + + if decompressed_size <= len(data): + break + + if len(data) != decompressed_size: + raise DecompressionError("decompressed size does not match the expected size") + + return data + + +class DecompressionError(ValueError): + pass diff --git a/worlds/cvcotm/options.py b/worlds/cvcotm/options.py new file mode 100644 index 000000000000..3f7d93661cc0 --- /dev/null +++ b/worlds/cvcotm/options.py @@ -0,0 +1,282 @@ +from dataclasses import dataclass +from Options import OptionGroup, Choice, Range, Toggle, PerGameCommonOptions, StartInventoryPool, DeathLink + + +class IgnoreCleansing(Toggle): + """ + Removes the logical requirement for the Cleansing to go beyond the first Underground Waterway rooms from either of the area's sides. You may be required to brave the harmful water without it. + """ + display_name = "Ignore Cleansing" + + +class AutoRun(Toggle): + """ + Makes Nathan always run when pressing left or right without needing to double-tap. + """ + display_name = "Auto Run" + + +class DSSPatch(Toggle): + """ + Patches out being able to pause during the DSS startup animation and switch the cards in the menu to use any combos you don't currently have, as well as changing the element of a summon to one you don't currently have. + """ + display_name = "DSS Patch" + + +class AlwaysAllowSpeedDash(Toggle): + """ + Allows activating the speed dash combo (Pluto + Griffin) without needing the respective cards first. + """ + display_name = "Always Allow Speed Dash" + + +class IronMaidenBehavior(Choice): + """ + Sets how the iron maiden barriers blocking the entrances to Underground Gallery and Waterway will behave. + Vanilla: Vanilla behavior. Must press the button guarded by Adramelech to break them. + Start Broken: The maidens will be broken from the start. + Detonator In Pool: Adds a Maiden Detonator item in the pool that will detonate the maidens when found. Adramelech will guard an extra check. + """ + display_name = "Iron Maiden Behavior" + option_vanilla = 0 + option_start_broken = 1 + option_detonator_in_pool = 2 + + +class RequiredLastKeys(Range): + """ + How many Last Keys are needed to open the door to the Ceremonial Room. This will lower if higher than Available Last Keys. + """ + range_start = 0 + range_end = 9 + default = 1 + display_name = "Required Last Keys" + + +class AvailableLastKeys(Range): + """ + How many Last Keys are in the pool in total. + To see this in-game, select the Last Key in the Magic Item menu (when you have at least one) or touch the Ceremonial Room door. + """ + range_start = 0 + range_end = 9 + default = 1 + display_name = "Available Last Keys" + + +class BuffRangedFamiliars(Toggle): + """ + Makes Familiar projectiles deal double damage to enemies. + """ + display_name = "Buff Ranged Familiars" + + +class BuffSubWeapons(Toggle): + """ + Increases damage dealt by sub-weapons and item crushes in Shooter and non-Shooter Modes. + """ + display_name = "Buff Sub-weapons" + + +class BuffShooterStrength(Toggle): + """ + Increases Nathan's strength in Shooter Mode to match his strength in Vampire Killer Mode. + """ + display_name = "Buff Shooter Strength" + + +class ItemDropRandomization(Choice): + """ + Randomizes what enemies drop what items as well as the drop rates for said items. + Bosses and candle enemies will be guaranteed to have high-tier items in all of their drop slots, and "easy" enemies (below 61 HP) will only drop low-tier items in all of theirs. + All other enemies will drop a low or mid-tier item in their common drop slot, and a low, mid, or high-tier item in their rare drop slot. + The common slot item has a 6-10% base chance of appearing, and the rare has a 3-6% chance. + If Tiered is chosen, all enemies below 144 (instead of 61) HP will be considered "easy", rare items that land on bosses will be exclusive to them, enemies with 144-369 HP will have a low-tier in its common slot and a mid-tier in its rare slot, and enemies with more than 369 HP will have a mid-tier in its common slot and a high-tier in its rare slot. + See the Game Page for more info. + """ + display_name = "Item Drop Randomization" + option_disabled = 0 + option_normal = 1 + option_tiered = 2 + default = 1 + + +class HalveDSSCardsPlaced(Toggle): + """ + Places only half of the DSS Cards in the item pool. + A valid combo that lets you freeze or petrify enemies to use as platforms will always be placed. + """ + display_name = "Halve DSS Cards Placed" + + +class Countdown(Choice): + """ + Displays, below and near the right side of the MP bar, the number of un-found progression/useful-marked items or the total check locations remaining in the area you are currently in. + """ + display_name = "Countdown" + option_none = 0 + option_majors = 1 + option_all_locations = 2 + default = 0 + + +class SubWeaponShuffle(Toggle): + """ + Randomizes which sub-weapon candles have which sub-weapons. + The total available count of each sub-weapon will be consistent with that of the vanilla game. + """ + display_name = "Sub-weapon Shuffle" + + +class DisableBattleArenaMPDrain(Toggle): + """ + Makes the Battle Arena not drain Nathan's MP, so that DSS combos can be used like normal. + """ + display_name = "Disable Battle Arena MP Drain" + + +class RequiredSkirmishes(Choice): + """ + Forces a Last Key after every boss or after every boss and the Battle Arena and forces the required Last Keys to enter the Ceremonial Room to 8 or 9 for All Bosses and All Bosses And Arena respectively. + The Available and Required Last Keys options will be overridden to the respective values. + """ + display_name = "Required Skirmishes" + option_none = 0 + option_all_bosses = 1 + option_all_bosses_and_arena = 2 + default = 0 + + +class EarlyEscapeItem(Choice): + """ + Ensures the chosen Catacomb escape item will be placed in a starting location within your own game, accessible with nothing. + """ + display_name = "Early Escape Item" + option_none = 0 + option_double = 1 + option_roc_wing = 2 + option_double_or_roc_wing = 3 + default = 1 + + +class NerfRocWing(Toggle): + """ + Initially nerfs the Roc Wing by removing its ability to jump infinitely and reducing its jump height. You can power it back up to its vanilla behavior by obtaining the following: + Double: Allows one jump in midair, using your double jump. + Kick Boots: Restores its vanilla jump height. + Both: Enables infinite midair jumping. + Note that holding A while Roc jumping will cause you to rise slightly higher; this is accounted for in logic. + """ + display_name = "Nerf Roc Wing" + + +class PlutoGriffinAirSpeed(Toggle): + """ + Increases Nathan's air speeds with the Pluto + Griffin combo active to be the same as his ground speeds. Anything made possible with the increased air speed is out of logic. + """ + display_name = "DSS Pluto and Griffin Run Speed in Air" + + +class SkipDialogues(Toggle): + """ + Skips all cutscene dialogue besides the ending. + """ + display_name = "Skip Cutscene Dialogue" + + +class SkipTutorials(Toggle): + """ + Skips all Magic Item and DSS-related tutorial textboxes. + """ + display_name = "Skip Magic Item Tutorials" + + +class BattleArenaMusic(Choice): + """ + Enables any looping song from the game to play inside the Battle Arena instead of it being silent the whole time. + """ + display_name = "Battle Arena Music" + option_nothing = 0 + option_requiem = 1 + option_a_vision_of_dark_secrets = 2 + option_inversion = 3 + option_awake = 4 + option_the_sinking_old_sanctuary = 5 + option_clockwork = 6 + option_shudder = 7 + option_fate_to_despair = 8 + option_aquarius = 9 + option_clockwork_mansion = 10 + option_big_battle = 11 + option_nightmare = 12 + option_vampire_killer = 13 + option_illusionary_dance = 14 + option_proof_of_blood = 15 + option_repose_of_souls = 16 + option_circle_of_the_moon = 17 + default = 0 + + +class CVCotMDeathLink(Choice): + __doc__ = (DeathLink.__doc__ + + "\n\n Received DeathLinks will not kill you in the Battle Arena unless Arena On is chosen.") + display_name = "Death Link" + option_off = 0 + alias_false = 0 + alias_no = 0 + option_on = 1 + alias_true = 1 + alias_yes = 1 + option_arena_on = 2 + default = 0 + + +class CompletionGoal(Choice): + """ + The goal for game completion. Can be defeating Dracula, winning in the Battle Arena, or both. + If you aren't sure which one you have while playing, select the Dash Boots in the Magic Item menu. + """ + display_name = "Completion Goal" + option_dracula = 0 + option_battle_arena = 1 + option_battle_arena_and_dracula = 2 + default = 0 + + +@dataclass +class CVCotMOptions(PerGameCommonOptions): + start_inventory_from_pool: StartInventoryPool + completion_goal: CompletionGoal + ignore_cleansing: IgnoreCleansing + auto_run: AutoRun + dss_patch: DSSPatch + always_allow_speed_dash: AlwaysAllowSpeedDash + iron_maiden_behavior: IronMaidenBehavior + required_last_keys: RequiredLastKeys + available_last_keys: AvailableLastKeys + buff_ranged_familiars: BuffRangedFamiliars + buff_sub_weapons: BuffSubWeapons + buff_shooter_strength: BuffShooterStrength + item_drop_randomization: ItemDropRandomization + halve_dss_cards_placed: HalveDSSCardsPlaced + countdown: Countdown + sub_weapon_shuffle: SubWeaponShuffle + disable_battle_arena_mp_drain: DisableBattleArenaMPDrain + required_skirmishes: RequiredSkirmishes + pluto_griffin_air_speed: PlutoGriffinAirSpeed + skip_dialogues: SkipDialogues + skip_tutorials: SkipTutorials + nerf_roc_wing: NerfRocWing + early_escape_item: EarlyEscapeItem + battle_arena_music: BattleArenaMusic + death_link: CVCotMDeathLink + + +cvcotm_option_groups = [ + OptionGroup("difficulty", [ + BuffRangedFamiliars, BuffSubWeapons, BuffShooterStrength, ItemDropRandomization, IgnoreCleansing, + HalveDSSCardsPlaced, SubWeaponShuffle, EarlyEscapeItem, CVCotMDeathLink]), + OptionGroup("quality of life", [ + AutoRun, DSSPatch, AlwaysAllowSpeedDash, PlutoGriffinAirSpeed, Countdown, DisableBattleArenaMPDrain, + SkipDialogues, SkipTutorials, BattleArenaMusic]) +] diff --git a/worlds/cvcotm/presets.py b/worlds/cvcotm/presets.py new file mode 100644 index 000000000000..7865935c7c7a --- /dev/null +++ b/worlds/cvcotm/presets.py @@ -0,0 +1,190 @@ +from typing import Any, Dict + +from Options import Accessibility, ProgressionBalancing +from .options import IgnoreCleansing, AutoRun, DSSPatch, AlwaysAllowSpeedDash, IronMaidenBehavior, BuffRangedFamiliars,\ + BuffSubWeapons, BuffShooterStrength, ItemDropRandomization, HalveDSSCardsPlaced, Countdown, SubWeaponShuffle,\ + DisableBattleArenaMPDrain, RequiredSkirmishes, EarlyEscapeItem, CVCotMDeathLink, CompletionGoal, SkipDialogues,\ + NerfRocWing, SkipTutorials, BattleArenaMusic, PlutoGriffinAirSpeed + +all_random_options = { + "progression_balancing": "random", + "accessibility": "random", + "ignore_cleansing": "random", + "auto_run": "random", + "dss_patch": "random", + "always_allow_speed_dash": "random", + "iron_maiden_behavior": "random", + "required_last_keys": "random", + "available_last_keys": "random", + "buff_ranged_familiars": "random", + "buff_sub_weapons": "random", + "buff_shooter_strength": "random", + "item_drop_randomization": "random", + "halve_dss_cards_placed": "random", + "countdown": "random", + "sub_weapon_shuffle": "random", + "disable_battle_arena_mp_drain": "random", + "required_skirmishes": "random", + "pluto_griffin_air_speed": "random", + "skip_dialogues": "random", + "skip_tutorials": "random", + "nerf_roc_wing": "random", + "early_escape_item": "random", + "battle_arena_music": "random", + "death_link": CVCotMDeathLink.option_off, + "completion_goal": "random", +} + +beginner_mode_options = { + "progression_balancing": ProgressionBalancing.default, + "accessibility": Accessibility.option_full, + "ignore_cleansing": IgnoreCleansing.option_false, + "auto_run": AutoRun.option_true, + "dss_patch": DSSPatch.option_true, + "always_allow_speed_dash": AlwaysAllowSpeedDash.option_true, + "iron_maiden_behavior": IronMaidenBehavior.option_start_broken, + "required_last_keys": 3, + "available_last_keys": 6, + "buff_ranged_familiars": BuffRangedFamiliars.option_true, + "buff_sub_weapons": BuffSubWeapons.option_true, + "buff_shooter_strength": BuffShooterStrength.option_true, + "item_drop_randomization": ItemDropRandomization.option_normal, + "halve_dss_cards_placed": HalveDSSCardsPlaced.option_false, + "countdown": Countdown.option_majors, + "sub_weapon_shuffle": SubWeaponShuffle.option_false, + "disable_battle_arena_mp_drain": DisableBattleArenaMPDrain.option_true, + "required_skirmishes": RequiredSkirmishes.option_none, + "pluto_griffin_air_speed": PlutoGriffinAirSpeed.option_false, + "skip_dialogues": SkipDialogues.option_false, + "skip_tutorials": SkipTutorials.option_false, + "nerf_roc_wing": NerfRocWing.option_false, + "early_escape_item": EarlyEscapeItem.option_double, + "battle_arena_music": BattleArenaMusic.option_nothing, + "death_link": CVCotMDeathLink.option_off, + "completion_goal": CompletionGoal.option_dracula, +} + +standard_competitive_options = { + "progression_balancing": ProgressionBalancing.default, + "accessibility": Accessibility.option_full, + "ignore_cleansing": IgnoreCleansing.option_false, + "auto_run": AutoRun.option_false, + "dss_patch": DSSPatch.option_true, + "always_allow_speed_dash": AlwaysAllowSpeedDash.option_true, + "iron_maiden_behavior": IronMaidenBehavior.option_start_broken, + "required_last_keys": 3, + "available_last_keys": 5, + "buff_ranged_familiars": BuffRangedFamiliars.option_true, + "buff_sub_weapons": BuffSubWeapons.option_true, + "buff_shooter_strength": BuffShooterStrength.option_false, + "item_drop_randomization": ItemDropRandomization.option_normal, + "halve_dss_cards_placed": HalveDSSCardsPlaced.option_true, + "countdown": Countdown.option_majors, + "sub_weapon_shuffle": SubWeaponShuffle.option_true, + "disable_battle_arena_mp_drain": DisableBattleArenaMPDrain.option_false, + "required_skirmishes": RequiredSkirmishes.option_none, + "pluto_griffin_air_speed": PlutoGriffinAirSpeed.option_false, + "skip_dialogues": SkipDialogues.option_true, + "skip_tutorials": SkipTutorials.option_true, + "nerf_roc_wing": NerfRocWing.option_false, + "early_escape_item": EarlyEscapeItem.option_double, + "battle_arena_music": BattleArenaMusic.option_nothing, + "death_link": CVCotMDeathLink.option_off, + "completion_goal": CompletionGoal.option_dracula, +} + +randomania_2023_options = { + "progression_balancing": ProgressionBalancing.default, + "accessibility": Accessibility.option_full, + "ignore_cleansing": IgnoreCleansing.option_false, + "auto_run": AutoRun.option_false, + "dss_patch": DSSPatch.option_true, + "always_allow_speed_dash": AlwaysAllowSpeedDash.option_true, + "iron_maiden_behavior": IronMaidenBehavior.option_vanilla, + "required_last_keys": 3, + "available_last_keys": 5, + "buff_ranged_familiars": BuffRangedFamiliars.option_true, + "buff_sub_weapons": BuffSubWeapons.option_true, + "buff_shooter_strength": BuffShooterStrength.option_false, + "item_drop_randomization": ItemDropRandomization.option_normal, + "halve_dss_cards_placed": HalveDSSCardsPlaced.option_false, + "countdown": Countdown.option_majors, + "sub_weapon_shuffle": SubWeaponShuffle.option_true, + "disable_battle_arena_mp_drain": DisableBattleArenaMPDrain.option_false, + "required_skirmishes": RequiredSkirmishes.option_none, + "pluto_griffin_air_speed": PlutoGriffinAirSpeed.option_false, + "skip_dialogues": SkipDialogues.option_false, + "skip_tutorials": SkipTutorials.option_false, + "nerf_roc_wing": NerfRocWing.option_false, + "early_escape_item": EarlyEscapeItem.option_double, + "battle_arena_music": BattleArenaMusic.option_nothing, + "death_link": CVCotMDeathLink.option_off, + "completion_goal": CompletionGoal.option_dracula, +} + +competitive_all_bosses_options = { + "progression_balancing": ProgressionBalancing.default, + "accessibility": Accessibility.option_full, + "ignore_cleansing": IgnoreCleansing.option_false, + "auto_run": AutoRun.option_false, + "dss_patch": DSSPatch.option_true, + "always_allow_speed_dash": AlwaysAllowSpeedDash.option_true, + "iron_maiden_behavior": IronMaidenBehavior.option_vanilla, + "required_last_keys": 8, + "available_last_keys": 8, + "buff_ranged_familiars": BuffRangedFamiliars.option_true, + "buff_sub_weapons": BuffSubWeapons.option_true, + "buff_shooter_strength": BuffShooterStrength.option_false, + "item_drop_randomization": ItemDropRandomization.option_tiered, + "halve_dss_cards_placed": HalveDSSCardsPlaced.option_true, + "countdown": Countdown.option_none, + "sub_weapon_shuffle": SubWeaponShuffle.option_true, + "disable_battle_arena_mp_drain": DisableBattleArenaMPDrain.option_false, + "required_skirmishes": RequiredSkirmishes.option_all_bosses, + "pluto_griffin_air_speed": PlutoGriffinAirSpeed.option_false, + "skip_dialogues": SkipDialogues.option_true, + "skip_tutorials": SkipTutorials.option_true, + "nerf_roc_wing": NerfRocWing.option_false, + "early_escape_item": EarlyEscapeItem.option_double, + "battle_arena_music": BattleArenaMusic.option_nothing, + "death_link": CVCotMDeathLink.option_off, + "completion_goal": CompletionGoal.option_dracula, +} + +hardcore_mode_options = { + "progression_balancing": ProgressionBalancing.default, + "accessibility": Accessibility.option_minimal, + "ignore_cleansing": IgnoreCleansing.option_true, + "auto_run": AutoRun.option_false, + "dss_patch": DSSPatch.option_true, + "always_allow_speed_dash": AlwaysAllowSpeedDash.option_false, + "iron_maiden_behavior": IronMaidenBehavior.option_vanilla, + "required_last_keys": 9, + "available_last_keys": 9, + "buff_ranged_familiars": BuffRangedFamiliars.option_false, + "buff_sub_weapons": BuffSubWeapons.option_false, + "buff_shooter_strength": BuffShooterStrength.option_false, + "item_drop_randomization": ItemDropRandomization.option_tiered, + "halve_dss_cards_placed": HalveDSSCardsPlaced.option_true, + "countdown": Countdown.option_none, + "sub_weapon_shuffle": SubWeaponShuffle.option_true, + "disable_battle_arena_mp_drain": DisableBattleArenaMPDrain.option_false, + "required_skirmishes": RequiredSkirmishes.option_none, + "pluto_griffin_air_speed": PlutoGriffinAirSpeed.option_false, + "skip_dialogues": SkipDialogues.option_false, + "skip_tutorials": SkipTutorials.option_false, + "nerf_roc_wing": NerfRocWing.option_false, + "early_escape_item": EarlyEscapeItem.option_double, + "battle_arena_music": BattleArenaMusic.option_nothing, + "death_link": CVCotMDeathLink.option_off, + "completion_goal": CompletionGoal.option_battle_arena_and_dracula, +} + +cvcotm_options_presets: Dict[str, Dict[str, Any]] = { + "All Random": all_random_options, + "Beginner Mode": beginner_mode_options, + "Standard Competitive": standard_competitive_options, + "Randomania 2023": randomania_2023_options, + "Competitive All Bosses": competitive_all_bosses_options, + "Hardcore Mode": hardcore_mode_options, +} diff --git a/worlds/cvcotm/regions.py b/worlds/cvcotm/regions.py new file mode 100644 index 000000000000..5403d12c81a3 --- /dev/null +++ b/worlds/cvcotm/regions.py @@ -0,0 +1,189 @@ +from .data import lname +from typing import Dict, List, Optional, TypedDict, Union + + +class RegionInfo(TypedDict, total=False): + locations: List[str] + entrances: Dict[str, str] + + +# # # KEY # # # +# "locations" = A list of the Locations to add to that Region when adding said Region. +# "entrances" = A dict of the connecting Regions to the Entrances' names to add to that Region when adding said Region. +cvcotm_region_info: Dict[str, RegionInfo] = { + "Catacomb": {"locations": [lname.sr3, + lname.cc1, + lname.cc3, + lname.cc3b, + lname.cc4, + lname.cc5, + lname.cc8, + lname.cc8b, + lname.cc9, + lname.cc10, + lname.cc13, + lname.cc14, + lname.cc14b, + lname.cc16, + lname.cc20, + lname.cc22, + lname.cc24, + lname.cc25], + "entrances": {"Abyss Stairway": "Catacomb to Stairway"}}, + + "Abyss Stairway": {"locations": [lname.as2, + lname.as3], + "entrances": {"Audience Room": "Stairway to Audience"}}, + + "Audience Room": {"locations": [lname.as4, + lname.as9, + lname.ar4, + lname.ar7, + lname.ar8, + lname.ar9, + lname.ar10, + lname.ar11, + lname.ar14, + lname.ar14b, + lname.ar16, + lname.ar17, + lname.ar17b, + lname.ar18, + lname.ar19, + lname.ar21, + lname.ar25, + lname.ar26, + lname.ar27, + lname.ar30, + lname.ar30b, + lname.ow0, + lname.ow1, + lname.ow2, + lname.th1, + lname.th3], + "entrances": {"Machine Tower Bottom": "Audience to Machine Bottom", + "Machine Tower Top": "Audience to Machine Top", + "Chapel Tower Bottom": "Audience to Chapel", + "Underground Gallery Lower": "Audience to Gallery", + "Underground Warehouse Start": "Audience to Warehouse", + "Underground Waterway Start": "Audience to Waterway", + "Observation Tower": "Audience to Observation", + "Ceremonial Room": "Ceremonial Door"}}, + + "Machine Tower Bottom": {"locations": [lname.mt0, + lname.mt2, + lname.mt3, + lname.mt4, + lname.mt6, + lname.mt8, + lname.mt10, + lname.mt11], + "entrances": {"Machine Tower Top": "Machine Bottom to Top"}}, + + "Machine Tower Top": {"locations": [lname.mt13, + lname.mt14, + lname.mt17, + lname.mt19]}, + + "Eternal Corridor Pit": {"locations": [lname.ec5], + "entrances": {"Underground Gallery Upper": "Corridor to Gallery", + "Chapel Tower Bottom": "Escape the Gallery Pit"}}, + + "Chapel Tower Bottom": {"locations": [lname.ec7, + lname.ec9, + lname.ct1, + lname.ct4, + lname.ct5, + lname.ct6, + lname.ct6b, + lname.ct8, + lname.ct10, + lname.ct13, + lname.ct15], + "entrances": {"Eternal Corridor Pit": "Into the Corridor Pit", + "Underground Waterway End": "Dip Into Waterway End", + "Chapel Tower Top": "Climb to Chapel Top"}}, + + "Chapel Tower Top": {"locations": [lname.ct16, + lname.ct18, + lname.ct21, + lname.ct22], + "entrances": {"Battle Arena": "Arena Passage"}}, + + "Battle Arena": {"locations": [lname.ct26, + lname.ct26b, + lname.ba24, + lname.arena_victory]}, + + "Underground Gallery Upper": {"locations": [lname.ug0, + lname.ug1, + lname.ug2, + lname.ug3, + lname.ug3b], + "entrances": {"Eternal Corridor Pit": "Gallery to Corridor", + "Underground Gallery Lower": "Gallery Upper to Lower"}}, + + "Underground Gallery Lower": {"locations": [lname.ug8, + lname.ug10, + lname.ug13, + lname.ug15, + lname.ug20], + "entrances": {"Underground Gallery Upper": "Gallery Lower to Upper"}}, + + "Underground Warehouse Start": {"locations": [lname.uw1], + "entrances": {"Underground Warehouse Main": "Into Warehouse Main"}}, + + "Underground Warehouse Main": {"locations": [lname.uw6, + lname.uw8, + lname.uw9, + lname.uw10, + lname.uw11, + lname.uw14, + lname.uw16, + lname.uw16b, + lname.uw19, + lname.uw23, + lname.uw24, + lname.uw25]}, + + "Underground Waterway Start": {"locations": [lname.uy1], + "entrances": {"Underground Waterway Main": "Into Waterway Main"}}, + + "Underground Waterway Main": {"locations": [lname.uy3, + lname.uy3b, + lname.uy4, + lname.uy5, + lname.uy7, + lname.uy8, + lname.uy9, + lname.uy9b, + lname.uy12], + "entrances": {"Underground Waterway End": "Onward to Waterway End"}}, + + "Underground Waterway End": {"locations": [lname.uy12b, + lname.uy13, + lname.uy17, + lname.uy18]}, + + "Observation Tower": {"locations": [lname.ot1, + lname.ot2, + lname.ot3, + lname.ot5, + lname.ot8, + lname.ot9, + lname.ot12, + lname.ot13, + lname.ot16, + lname.ot20]}, + + "Ceremonial Room": {"locations": [lname.cr1, + lname.dracula]}, +} + + +def get_region_info(region: str, info: str) -> Optional[Union[List[str], Dict[str, str]]]: + return cvcotm_region_info[region].get(info, None) + + +def get_all_region_names() -> List[str]: + return [reg_name for reg_name in cvcotm_region_info] diff --git a/worlds/cvcotm/rom.py b/worlds/cvcotm/rom.py new file mode 100644 index 000000000000..e7b0710d134e --- /dev/null +++ b/worlds/cvcotm/rom.py @@ -0,0 +1,600 @@ + +import Utils +import logging +import json + +from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes, APPatchExtension +from typing import Dict, Optional, Collection, TYPE_CHECKING + +import hashlib +import os +import pkgutil + +from .data import patches +from .locations import cvcotm_location_info +from .cvcotm_text import cvcotm_string_to_bytearray +from .options import CompletionGoal, IronMaidenBehavior, RequiredSkirmishes +from .lz10 import decompress +from settings import get_settings + +if TYPE_CHECKING: + from . import CVCotMWorld + +CVCOTM_CT_US_HASH = "50a1089600603a94e15ecf287f8d5a1f" # Original GBA cartridge ROM +CVCOTM_AC_US_HASH = "87a1bd6577b6702f97a60fc55772ad74" # Castlevania Advance Collection ROM +CVCOTM_VC_US_HASH = "2cc38305f62b337281663bad8c901cf9" # Wii U Virtual Console ROM + +# NOTE: The Wii U VC version is untested as of when this comment was written. I am only including its hash in case it +# does work. If someone who has it can confirm it does indeed work, this comment should be removed. If it doesn't, the +# hash should be removed in addition. See the Game Page for more information about supported versions. + +ARCHIPELAGO_IDENTIFIER_START = 0x7FFF00 +ARCHIPELAGO_IDENTIFIER = "ARCHIPELAG03" +AUTH_NUMBER_START = 0x7FFF10 +QUEUED_TEXT_STRING_START = 0x7CEB00 +MULTIWORLD_TEXTBOX_POINTERS_START = 0x671C10 + +BATTLE_ARENA_SONG_IDS = [0x01, 0x03, 0x12, 0x06, 0x08, 0x09, 0x07, 0x0A, 0x0B, + 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x13, 0x14] + + +class RomData: + def __init__(self, file: bytes, name: Optional[str] = None) -> None: + self.file = bytearray(file) + self.name = name + + def read_byte(self, offset: int) -> int: + return self.file[offset] + + def read_bytes(self, offset: int, length: int) -> bytes: + return self.file[offset:offset + length] + + def write_byte(self, offset: int, value: int) -> None: + self.file[offset] = value + + def write_bytes(self, offset: int, values: Collection[int]) -> None: + self.file[offset:offset + len(values)] = values + + def get_bytes(self) -> bytes: + return bytes(self.file) + + def apply_ips(self, filename: str) -> None: + # Try loading the IPS file. + try: + ips_file = pkgutil.get_data(__name__, "data/ips/" + filename) + except IOError: + raise Exception(f"{filename} is not present in the ips folder. If it was removed, please replace it.") + + # Verify that the IPS patch is, indeed, an IPS patch. + if ips_file[0:5].decode("ascii") != "PATCH": + logging.error(filename + " does not appear to be an IPS patch...") + return + + file_pos = 5 + while True: + # Get the ROM offset bytes of the current record. + rom_offset = int.from_bytes(ips_file[file_pos:file_pos + 3], "big") + + # If we've hit the "EOF" codeword (aka 0x454F46), stop iterating because we've reached the end of the patch. + if rom_offset == 0x454F46: + return + + # Get the size bytes of the current record. + bytes_size = int.from_bytes(ips_file[file_pos + 3:file_pos + 5], "big") + + if bytes_size != 0: + # Write the bytes to the ROM. + self.write_bytes(rom_offset, ips_file[file_pos + 5:file_pos + 5 + bytes_size]) + + # Increase our position in the IPS patch to the start of the next record. + file_pos += 5 + bytes_size + else: + # If the size is 0, we are looking at an RLE record. + # Get the size of the RLE. + rle_size = int.from_bytes(ips_file[file_pos + 5:file_pos + 7], "big") + + # Get the byte to be written over and over. + rle_byte = int.from_bytes(ips_file[file_pos + 7:file_pos + 8], "big") + + # Write the RLE byte to the ROM the RLE size times over. + self.write_bytes(rom_offset, [rle_byte for _ in range(rle_size)]) + + # Increase our position in the IPS patch to the start of the next record. + file_pos += 8 + + +class CVCotMPatchExtensions(APPatchExtension): + game = "Castlevania - Circle of the Moon" + + @staticmethod + def apply_patches(caller: APProcedurePatch, rom: bytes, options_file: str) -> bytes: + """Applies every patch to mod the game into its rando state, both CotMR's pre-made IPS patches and some + additional byte writes. Each patch is credited to its author.""" + + rom_data = RomData(rom) + options = json.loads(caller.get_file(options_file).decode("utf-8")) + + # Check to see if the patch was generated on a compatible APWorld version. + if "compat_identifier" not in options: + raise Exception("Incompatible patch/APWorld version. Make sure the Circle of the Moon APWorlds of both you " + "and the person who generated are matching (and preferably up-to-date).") + if options["compat_identifier"] != ARCHIPELAGO_IDENTIFIER: + raise Exception("Incompatible patch/APWorld version. Make sure the Circle of the Moon APWorlds of both you " + "and the person who generated are matching (and preferably up-to-date).") + + # This patch allows placing DSS cards on pedestals, prevents them from timing out, and removes them from enemy + # drop tables. Created by DevAnj originally as a standalone hack known as Card Mode, it has been modified for + # this randomizer's purposes by stripping out additional things like drop and pedestal item replacements. + + # Further modified by Liquid Cat to make placed cards set their flags upon pickup (instead of relying on whether + # the card is in the player's inventory when determining to spawn it or not), enable placing dummy DSS Cards to + # represent other players' Cards in a multiworld setting, and turn specific cards blue to visually indicate + # their status as valid ice/stone combo cards. + rom_data.apply_ips("CardUp_v3_Custom2.ips") + + # This patch replaces enemy drops that included DSS cards. Created by DevAnj as part of the Card Up patch but + # modified for different replacement drops (Lowered rate, Potion instead of Meat, and no Shinning Armor change + # on Devil). + rom_data.apply_ips("NoDSSDrops.ips") + + # This patch reveals card combination descriptions instead of showing "???" until the combination is used. + # Created by DevAnj. + rom_data.apply_ips("CardCombosRevealed.ips") + + # In lategame, the Trick Candle and Scary Candle load in the Cerberus and Iron Golem boss rooms after defeating + # Camilla and Twin Dragon Zombies respectively. If the former bosses have not yet been cleared (i.e., we have + # sequence broken the game and returned to the earlier boss rooms to fight them), the candle enemies will cause + # the bosses to fail to load and soft lock the game. This patches the candles to appear after the early boss is + # completed instead. + # Created by DevAnj. + rom_data.apply_ips("CandleFix.ips") + + # A Tackle block in Machine Tower will cause a softlock if you access the Machine Tower from the Audience Room + # using the stone tower route with Kick Boots and not Double. This is a small level edit that moves that block + # slightly, removing the potential for a softlock. + # Created by DevAnj. + rom_data.apply_ips("SoftlockBlockFix.ips") + + # Normally, the MP boosting card combination is useless since it depletes more MP than it gains. This patch + # makes it consume zero MP. + # Created by DevAnj. + rom_data.apply_ips("MPComboFix.ips") + + # Normally, you must clear the game with each mode to unlock subsequent modes, and complete the game at least + # once to be able to skip the introductory text crawl. This allows all game modes to be selected and the + # introduction to be skipped even without game/mode completion. + # Created by DevAnj. + rom_data.apply_ips("GameClearBypass.ips") + + # This patch adds custom mapping in Underground Gallery and Underground Waterway to avoid softlocking/Kick Boots + # requirements. + # Created by DevAnj. + rom_data.apply_ips("MapEdits.ips") + + # Prevents demos on the main title screen after the first one from being displayed to avoid pedestal item + # reconnaissance from the menu. + # Created by Fusecavator. + rom_data.apply_ips("DemoForceFirst.ips") + + # Used internally in the item randomizer to allow setting drop rate to 10000 (100%) and actually drop the item + # 100% of the time. Normally, it is hard capped at 50% for common drops and 25% for rare drops. + # Created by Fusecavator. + rom_data.apply_ips("AllowAlwaysDrop.ips") + + # Displays the seed on the pause menu. Originally created by Fusecavator and modified by Liquid Cat to display a + # 20-digit seed (which AP seeds most commonly are). + rom_data.apply_ips("SeedDisplay20Digits.ips") + + # Write the seed. Upwards of 20 digits can be displayed for the seed number. + curr_seed_addr = 0x672152 + total_digits = 0 + while options["seed"] and total_digits < 20: + seed_digit = (options["seed"] % 10) + 0x511C + rom_data.write_bytes(curr_seed_addr, int.to_bytes(seed_digit, 2, "little")) + curr_seed_addr -= 2 + total_digits += 1 + options["seed"] //= 10 + + # Optional patch created by Fusecavator. Permanent dash effect without double tapping. + if options["auto_run"]: + rom_data.apply_ips("PermanentDash.ips") + + # Optional patch created by Fusecavator. Prohibits the DSS glitch. You will not be able to update the active + # effect unless the card combination switched to is obtained. For example, if you switch to another DSS + # combination that you have not obtained during DSS startup, you will still have the effect of the original + # combination you had selected when you started the DSS activation. In addition, you will not be able to + # increase damage and/or change the element of a summon attack unless you possess the cards you swap to. + if options["dss_patch"]: + rom_data.apply_ips("DSSGlitchFix.ips") + + # Optional patch created by DevAnj. Breaks the iron maidens blocking access to the Underground Waterway, + # Underground Gallery, and the room beyond the Adramelech boss room from the beginning of the game. + if options["break_iron_maidens"]: + rom_data.apply_ips("BrokenMaidens.ips") + + # Optional patch created by Fusecavator. Changes game behavior to add instead of set Last Key values, and check + # for a specific value of Last Keys on the door to the Ceremonial Room, allowing multiple keys to be required to + # complete the game. Relies on the program to set required key values. + if options["required_last_keys"] != 1: + rom_data.apply_ips("MultiLastKey.ips") + rom_data.write_byte(0x96C1E, options["required_last_keys"]) + rom_data.write_byte(0xDFB4, options["required_last_keys"]) + rom_data.write_byte(0xCB84, options["required_last_keys"]) + + # Optional patch created by Fusecavator. Doubles the damage dealt by projectiles fired by ranged familiars. + if options["buff_ranged_familiars"]: + rom_data.apply_ips("BuffFamiliars.ips") + + # Optional patch created by Fusecavator. Increases the base damage dealt by some sub-weapons. + # Changes below (normal multiplier on left/shooter on right): + # Original: Changed: + # Dagger: 45 / 141 ----> 100 / 141 (Non-Shooter buffed) + # Dagger crush: 32 / 45 ----> 100 / 141 (Both buffed to match non-crush values) + # Axe: 89 / 158 ----> 125 / 158 (Non-Shooter somewhat buffed) + # Axe crush: 89 / 126 ----> 125 / 158 (Both buffed to match non-crush values) + # Holy water: 63 / 100 ----> 63 / 100 (Unchanged) + # Holy water crush: 45 / 63 ----> 63 / 100 (Large buff to Shooter, non-Shooter slightly buffed) + # Cross: 110 / 173 ----> 110 / 173 (Unchanged) + # Cross crush: 100 / 141 ----> 110 / 173 (Slightly buffed to match non-crush values) + if options["buff_sub_weapons"]: + rom_data.apply_ips("BuffSubweapons.ips") + + # Optional patch created by Fusecavator. Increases the Shooter gamemode base strength and strength per level to + # match Vampire Killer. + if options["buff_shooter_strength"]: + rom_data.apply_ips("ShooterStrength.ips") + + # Optional patch created by Fusecavator. Allows using the Pluto + Griffin combination for the speed boost with + # or without the cards being obtained. + if options["always_allow_speed_dash"]: + rom_data.apply_ips("AllowSpeedDash.ips") + + # Optional patch created by fuse. Displays a counter on the HUD showing the number of magic items and cards + # remaining in the current area. Requires a lookup table generated by the randomizer to function. + if options["countdown"]: + rom_data.apply_ips("Countdown.ips") + + # This patch disables the MP drain effect in the Battle Arena. + # Created by Fusecavator. + if options["disable_battle_arena_mp_drain"]: + rom_data.apply_ips("NoMPDrain.ips") + + # Patch created by Fusecavator. Makes various changes to dropped item graphics to avoid garbled Magic Items and + # allow displaying arbitrary items on pedestals. Modified by Liquid Cat for the purposes of changing the + # appearances of items regardless of what they really are, as well as allowing additional Magic Items. + rom_data.apply_ips("DropReworkMultiEdition.ips") + # Decompress the Magic Item graphics and reinsert them (decompressed) where the patch expects them. + # Doing it this way is more copyright-safe. + rom_data.write_bytes(0x678C00, decompress(rom_data.read_bytes(0x630690, 0x605))[0x300:]) + + # Everything past here was added by Liquid Cat. + + # Makes the Pluto + Griffin speed increase apply even while in the air, instead of losing it. + if options["pluto_griffin_air_speed"]: + rom_data.apply_ips("DSSRunSpeed.ips") + + # Move the item sprite info table. + rom_data.write_bytes(0x678A00, rom_data.read_bytes(0x630B98, 0x98)) + # Update the ldr numbers pointing to the above item sprite table. + rom_data.write_bytes(0x95A08, [0x00, 0x8A, 0x67, 0x08]) + rom_data.write_bytes(0x100380, [0x00, 0x8A, 0x67, 0x08]) + # Move the magic item text ID table. + rom_data.write_bytes(0x6788B0, rom_data.read_bytes(0x100A7E, 0x48)) + # Update the ldr numbers pointing to the above magic item text ID table. + rom_data.write_bytes(0x95C10, [0xB0, 0x88, 0x67, 0x08]) + rom_data.write_bytes(0x95CE0, [0xB0, 0x88, 0x67, 0x08]) + # Move the magic item pickup function jump table. + rom_data.write_bytes(0x678B20, rom_data.read_bytes(0x95B80, 0x24)) + # Update the ldr number point to the above jump table. + rom_data.write_bytes(0x95B7C, [0x20, 0x8B, 0x67, 0x08]) + rom_data.write_byte(0x95B6A, 0x09) # Raise the magic item function index limit. + + # Make the Maiden Detonator detonate the maidens when picked up. + rom_data.write_bytes(0x678B44, [0x90, 0x1F, 0x67, 0x08]) + rom_data.write_bytes(0x671F90, patches.maiden_detonator) + # Add the text for detonating the maidens. + rom_data.write_bytes(0x671C0C, [0xC0, 0x1F, 0x67, 0x08]) + rom_data.write_bytes(0x671FC0, cvcotm_string_to_bytearray(" 「Iron Maidens」 broken◊", "little middle", 0, + wrap=False)) + + # Put the new text string IDs for all our new items. + rom_data.write_bytes(0x6788F8, [0xF1, 0x84, 0xF1, 0x84, 0xF1, 0x84, 0xF1, 0x84, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) + # Have the game get the entry in that table to use by adding the item's parameter. + rom_data.write_bytes(0x95980, [0x0A, 0x30, 0x00, 0x00, 0x00, 0x00]) + # Add the AP Item sprites and their associated info. + rom_data.write_bytes(0x679080, patches.extra_item_sprites) + rom_data.write_bytes(0x678A98, [0xF8, 0xFF, 0xF8, 0xFF, 0xFC, 0x21, 0x45, 0x00, + 0xF8, 0xFF, 0xF8, 0xFF, 0x00, 0x22, 0x45, 0x00, + 0xF8, 0xFF, 0xF8, 0xFF, 0x04, 0x22, 0x45, 0x00, + 0xF8, 0xFF, 0xF8, 0xFF, 0x08, 0x22, 0x45, 0x00, + 0xF8, 0xFF, 0xF8, 0xFF, 0x0C, 0x22, 0x45, 0x00, + 0xF8, 0xFF, 0xF8, 0xFF, 0x10, 0x22, 0x45, 0x00, + 0xF8, 0xFF, 0xF8, 0xFF, 0x14, 0x32, 0x45, 0x00]) + # Enable changing the Magic Item appearance separately from what it really is. + # Change these ldrh's to ldrb's to read only the high or low byte of the object list entry's parameter field. + rom_data.write_bytes(0x9597A, [0xC1, 0x79]) + rom_data.write_bytes(0x95B64, [0x80, 0x79]) + rom_data.write_bytes(0x95BF0, [0x81, 0x79]) + rom_data.write_bytes(0x95CBE, [0x82, 0x79]) + # Enable changing the Max Up appearance separately from what it really is. + rom_data.write_bytes(0x5DE98, [0xC1, 0x79]) + rom_data.write_byte(0x5E152, 0x13) + rom_data.write_byte(0x5E15C, 0x0E) + rom_data.write_byte(0x5E20A, 0x0B) + + # Set the 0xF0 flag on the iron maiden switch if we're placing an Item on it. + if options["iron_maiden_behavior"] == IronMaidenBehavior.option_detonator_in_pool: + rom_data.write_byte(0xD47B4, 0xF0) + + if options["nerf_roc_wing"]: + # Prevent Roc jumping in midair if the Double is not in the player's inventory. + rom_data.write_bytes(0x6B8A0, [0x00, 0x4A, 0x97, 0x46, 0x00, 0x9A, 0x67, 0x08]) + rom_data.write_bytes(0x679A00, patches.doubleless_roc_midairs_preventer) + + # Make Roc Wing not jump as high if Kick Boots isn't in the inventory. + rom_data.write_bytes(0x6B8B4, [0x00, 0x49, 0x8F, 0x46, 0x60, 0x9A, 0x67, 0x08]) + rom_data.write_bytes(0x679A60, patches.kickless_roc_height_shortener) + + # Give the player their Start Inventory upon entering their name on a new file. + rom_data.write_bytes(0x7F70, [0x00, 0x48, 0x87, 0x46, 0x00, 0x00, 0x68, 0x08]) + rom_data.write_bytes(0x680000, patches.start_inventory_giver) + + # Prevent Max Ups from exceeding 255. + rom_data.write_bytes(0x5E170, [0x00, 0x4A, 0x97, 0x46, 0x00, 0x00, 0x6A, 0x08]) + rom_data.write_bytes(0x6A0000, patches.max_max_up_checker) + + # Write the textbox messaging system code. + rom_data.write_bytes(0x7D60, [0x00, 0x48, 0x87, 0x46, 0x20, 0xFF, 0x7F, 0x08]) + rom_data.write_bytes(0x7FFF20, patches.remote_textbox_shower) + + # Write the code that sets the screen transition delay timer. + rom_data.write_bytes(0x6CE14, [0x00, 0x4A, 0x97, 0x46, 0xC0, 0xFF, 0x7F, 0x08]) + rom_data.write_bytes(0x7FFFC0, patches.transition_textbox_delayer) + + # Write the code that allows any sound to be played with any Magic Item. + rom_data.write_bytes(0x95BE4, [0x00, 0x4A, 0x97, 0x46, 0x00, 0x98, 0x67, 0x08]) + rom_data.write_bytes(0x679800, patches.magic_item_sfx_customizer) + # Array of sound IDs for each Magic Item. + rom_data.write_bytes(0x6797C0, [0xB4, 0x01, 0xB4, 0x01, 0xB4, 0x01, 0xB4, 0x01, 0xB4, 0x01, 0xB4, 0x01, + 0xB4, 0x01, 0xB4, 0x01, 0xB4, 0x01, 0x79, 0x00]) + + # Write all the data for the missing ASCII text characters. + for offset, data in patches.missing_char_data.items(): + rom_data.write_bytes(offset, data) + + # Change all the menu item name strings that use the overwritten character IDs to use a different, equivalent + # space character ID. + rom_data.write_bytes(0x391A1B, [0xAD, 0xAD, 0xAD, 0xAD, 0xAD, 0xAD]) + rom_data.write_bytes(0x391CB6, [0xAD, 0xAD, 0xAD]) + rom_data.write_bytes(0x391CC1, [0xAD, 0xAD, 0xAD]) + rom_data.write_bytes(0x391CCB, [0xAD, 0xAD, 0xAD, 0xAD]) + rom_data.write_bytes(0x391CD5, [0xAD, 0xAD, 0xAD, 0xAD, 0xAD]) + rom_data.write_byte(0x391CE1, 0xAD) + + # Put the unused bottom-of-screen textbox in the middle of the screen instead. + # Its background's new y position will be 0x28 instead of 0x50. + rom_data.write_byte(0xBEDEA, 0x28) + # Change all the hardcoded checks for the 0x50 position to instead check for 0x28. + rom_data.write_byte(0xBF398, 0x28) + rom_data.write_byte(0xBF41C, 0x28) + rom_data.write_byte(0xBF4CC, 0x28) + # Change all the hardcoded checks for greater than 0x48 to instead check for 0x28 specifically. + rom_data.write_byte(0xBF4A4, 0x28) + rom_data.write_byte(0xBF4A7, 0xD0) + rom_data.write_byte(0xBF37E, 0x28) + rom_data.write_byte(0xBF381, 0xD0) + rom_data.write_byte(0xBF40A, 0x28) + rom_data.write_byte(0xBF40D, 0xD0) + # Change the y position of the contents within the textbox from 0xA0 to 0xB4. + # KCEK didn't program hardcoded checks for these, thankfully! + rom_data.write_byte(0xBF3BC, 0xB4) + + # Insert the multiworld message pointer at the end of the text pointers. + rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START, int.to_bytes(QUEUED_TEXT_STRING_START + 0x8000000, + 4, "little")) + # Insert pointers for every item tutorial. + rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 4, [0x8E, 0x3B, 0x39, 0x08]) + rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 8, [0xDF, 0x3B, 0x39, 0x08]) + rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 12, [0x35, 0x3C, 0x39, 0x08]) + rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 16, [0xC4, 0x3C, 0x39, 0x08]) + rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 20, [0x41, 0x3D, 0x39, 0x08]) + rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 24, [0x88, 0x3D, 0x39, 0x08]) + rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 28, [0xF7, 0x3D, 0x39, 0x08]) + rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 32, [0x67, 0x3E, 0x39, 0x08]) + + # Write the completion goal messages over the menu Dash Boots tutorial and Battle Arena's explanation message. + if options["completion_goal"] == CompletionGoal.option_dracula: + dash_tutorial_message = "Your goal is:\n Dracula◊" + if options["required_skirmishes"] == RequiredSkirmishes.option_all_bosses_and_arena: + arena_goal_message = "Your goal is:\n「Dracula」▶" \ + "A required 「Last Key」 is waiting for you at the end of the Arena. Good luck!◊" + else: + arena_goal_message = "Your goal is:\n「Dracula」▶" \ + "You don't have to win the Arena, but you are certainly welcome to try!◊" + elif options["completion_goal"] == CompletionGoal.option_battle_arena: + dash_tutorial_message = "Your goal is:\n Battle Arena◊" + arena_goal_message = "Your goal is:\n「Battle Arena」▶" \ + "Win the Arena, and your goal will send. Good luck!◊" + else: + dash_tutorial_message = "Your goal is:\n Arena and Dracula◊" + arena_goal_message = "Your goal is:\n「Battle Arena & Dracula」▶" \ + "Your goal will send once you've both won the Arena and beaten Dracula. Good luck!◊" + + rom_data.write_bytes(0x393EAE, cvcotm_string_to_bytearray(dash_tutorial_message, "big top", 4, + skip_textbox_controllers=True)) + rom_data.write_bytes(0x393A0C, cvcotm_string_to_bytearray(arena_goal_message, "big top", 4)) + + # Change the pointer to the Ceremonial Room locked door text. + rom_data.write_bytes(0x670D94, [0xE0, 0xE9, 0x7C, 0x08]) + # Write the Ceremonial Room door and menu Last Key tutorial messages telling the player's Last Key options. + door_message = f"Hmmmmmm...\nI need 「{options['required_last_keys']}」/" \ + f"「{options['available_last_keys']}」 Last Keys.◊" + key_tutorial_message = f"You need {options['required_last_keys']}/{options['available_last_keys']} keys.◊" + rom_data.write_bytes(0x7CE9E0, cvcotm_string_to_bytearray(door_message, "big top", 4, 0)) + rom_data.write_bytes(0x394098, cvcotm_string_to_bytearray(key_tutorial_message, "big top", 4, + skip_textbox_controllers=True)) + + # Nuke all the tutorial-related text if Skip Tutorials is enabled. + if options["skip_tutorials"]: + rom_data.write_byte(0x5EB55, 0xE0) # DSS + rom_data.write_byte(0x393B8C, 0x00) # Dash Boots + rom_data.write_byte(0x393BDD, 0x00) # Double + rom_data.write_byte(0x393C33, 0x00) # Tackle + rom_data.write_byte(0x393CC2, 0x00) # Kick Boots + rom_data.write_byte(0x393D41, 0x00) # Heavy Ring + rom_data.write_byte(0x393D86, 0x00) # Cleansing + rom_data.write_byte(0x393DF5, 0x00) # Roc Wing + rom_data.write_byte(0x393E65, 0x00) # Last Key + + # Nuke all the cutscene dialogue before the ending if Skip Dialogues is enabled. + if options["skip_dialogues"]: + rom_data.write_byte(0x392372, 0x00) + rom_data.write_bytes(0x3923C9, [0x20, 0x80, 0x00]) + rom_data.write_bytes(0x3924EE, [0x20, 0x81, 0x00]) + rom_data.write_byte(0x392621, 0x00) + rom_data.write_bytes(0x392650, [0x20, 0x81, 0x00]) + rom_data.write_byte(0x392740, 0x00) + rom_data.write_byte(0x3933C8, 0x00) + rom_data.write_byte(0x39346E, 0x00) + rom_data.write_byte(0x393670, 0x00) + rom_data.write_bytes(0x393698, [0x20, 0x80, 0x00]) + rom_data.write_byte(0x3936A6, 0x00) + rom_data.write_byte(0x393741, 0x00) + rom_data.write_byte(0x392944, 0x00) + rom_data.write_byte(0x392FFB, 0x00) + rom_data.write_byte(0x39305D, 0x00) + rom_data.write_byte(0x393114, 0x00) + rom_data.write_byte(0x392771, 0x00) + rom_data.write_byte(0x3928E9, 0x00) + rom_data.write_byte(0x392A3C, 0x00) + rom_data.write_byte(0x392A55, 0x00) + rom_data.write_byte(0x392A8B, 0x00) + rom_data.write_byte(0x392AA4, 0x00) + rom_data.write_byte(0x392AF4, 0x00) + rom_data.write_byte(0x392B3F, 0x00) + rom_data.write_byte(0x392C4D, 0x00) + rom_data.write_byte(0x392DEA, 0x00) + rom_data.write_byte(0x392E65, 0x00) + rom_data.write_byte(0x392F09, 0x00) + rom_data.write_byte(0x392FE4, 0x00) + + # Make the Battle Arena play the player's chosen track. + if options["battle_arena_music"]: + arena_track_id = BATTLE_ARENA_SONG_IDS[options["battle_arena_music"] - 1] + rom_data.write_bytes(0xEDEF0, [0xFC, 0xFF, arena_track_id]) + rom_data.write_bytes(0xEFA50, [0xFC, 0xFF, arena_track_id]) + rom_data.write_bytes(0xF24F0, [0xFC, 0xFF, arena_track_id]) + rom_data.write_bytes(0xF3420, [0xF5, 0xFF]) + rom_data.write_bytes(0xF3430, [0xFC, 0xFF, arena_track_id]) + + return rom_data.get_bytes() + + @staticmethod + def fix_item_positions(caller: APProcedurePatch, rom: bytes) -> bytes: + """After writing all the items into the ROM via token application, translates Magic Items in non-Magic Item + Locations up by 8 units and the reverse down by 8 units. This is necessary for them to look properly placed, + as Magic Items are offset differently on the Y axis from the other item types.""" + rom_data = RomData(rom) + for loc in cvcotm_location_info: + offset = cvcotm_location_info[loc].offset + if offset is None: + continue + item_type = rom_data.read_byte(offset) + + # Magic Items in non-Magic Item Locations should have their Y position decreased by 8. + if item_type == 0xE8 and cvcotm_location_info[loc].type not in ["magic item", "boss"]: + y_pos = int.from_bytes(rom_data.read_bytes(offset-2, 2), "little") + y_pos -= 8 + rom_data.write_bytes(offset-2, int.to_bytes(y_pos, 2, "little")) + + # Non-Magic Items in Magic Item Locations should have their Y position increased by 8. + if item_type != 0xE8 and cvcotm_location_info[loc].type in ["magic item", "boss"]: + y_pos = int.from_bytes(rom_data.read_bytes(offset - 2, 2), "little") + y_pos += 8 + rom_data.write_bytes(offset - 2, int.to_bytes(y_pos, 2, "little")) + + return rom_data.get_bytes() + + +class CVCotMProcedurePatch(APProcedurePatch, APTokenMixin): + hash = [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH] + patch_file_ending: str = ".apcvcotm" + result_file_ending: str = ".gba" + + game = "Castlevania - Circle of the Moon" + + procedure = [ + ("apply_patches", ["options.json"]), + ("apply_tokens", ["token_data.bin"]), + ("fix_item_positions", []) + ] + + @classmethod + def get_source_data(cls) -> bytes: + return get_base_rom_bytes() + + +def patch_rom(world: "CVCotMWorld", patch: CVCotMProcedurePatch, offset_data: Dict[int, bytes], + start_with_detonator: bool) -> None: + + # Write all the new item values + for offset, data in offset_data.items(): + patch.write_token(APTokenTypes.WRITE, offset, data) + + # Write the secondary name the client will use to distinguish a vanilla ROM from an AP one. + patch.write_token(APTokenTypes.WRITE, ARCHIPELAGO_IDENTIFIER_START, ARCHIPELAGO_IDENTIFIER.encode("utf-8")) + # Write the slot authentication + patch.write_token(APTokenTypes.WRITE, AUTH_NUMBER_START, bytes(world.auth)) + + patch.write_file("token_data.bin", patch.get_token_binary()) + + # Write these slot options to a JSON. + options_dict = { + "auto_run": world.options.auto_run.value, + "dss_patch": world.options.dss_patch.value, + "break_iron_maidens": start_with_detonator, + "iron_maiden_behavior": world.options.iron_maiden_behavior.value, + "required_last_keys": world.required_last_keys, + "available_last_keys": world.options.available_last_keys.value, + "required_skirmishes": world.options.required_skirmishes.value, + "buff_ranged_familiars": world.options.buff_ranged_familiars.value, + "buff_sub_weapons": world.options.buff_sub_weapons.value, + "buff_shooter_strength": world.options.buff_shooter_strength.value, + "always_allow_speed_dash": world.options.always_allow_speed_dash.value, + "countdown": world.options.countdown.value, + "disable_battle_arena_mp_drain": world.options.disable_battle_arena_mp_drain.value, + "completion_goal": world.options.completion_goal.value, + "skip_dialogues": world.options.skip_dialogues.value, + "skip_tutorials": world.options.skip_tutorials.value, + "nerf_roc_wing": world.options.nerf_roc_wing.value, + "pluto_griffin_air_speed": world.options.pluto_griffin_air_speed.value, + "battle_arena_music": world.options.battle_arena_music.value, + "seed": world.multiworld.seed, + "compat_identifier": ARCHIPELAGO_IDENTIFIER + } + + patch.write_file("options.json", json.dumps(options_dict).encode('utf-8')) + + +def get_base_rom_bytes(file_name: str = "") -> bytes: + base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None) + if not base_rom_bytes: + file_name = get_base_rom_path(file_name) + base_rom_bytes = bytes(open(file_name, "rb").read()) + + basemd5 = hashlib.md5() + basemd5.update(base_rom_bytes) + if basemd5.hexdigest() not in [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH]: + raise Exception("Supplied Base ROM does not match known MD5s for Castlevania: Circle of the Moon USA." + "Get the correct game and version, then dump it.") + setattr(get_base_rom_bytes, "base_rom_bytes", base_rom_bytes) + return base_rom_bytes + + +def get_base_rom_path(file_name: str = "") -> str: + if not file_name: + file_name = get_settings()["cvcotm_options"]["rom_file"] + if not os.path.exists(file_name): + file_name = Utils.user_path(file_name) + return file_name diff --git a/worlds/cvcotm/rules.py b/worlds/cvcotm/rules.py new file mode 100644 index 000000000000..01c240418804 --- /dev/null +++ b/worlds/cvcotm/rules.py @@ -0,0 +1,203 @@ +from typing import Dict, TYPE_CHECKING + +from BaseClasses import CollectionState +from worlds.generic.Rules import CollectionRule +from .data import iname, lname +from .options import CompletionGoal, IronMaidenBehavior + +if TYPE_CHECKING: + from . import CVCotMWorld + + +class CVCotMRules: + player: int + world: "CVCotMWorld" + rules: Dict[str, CollectionRule] + required_last_keys: int + iron_maiden_behavior: int + nerf_roc_wing: int + ignore_cleansing: int + completion_goal: int + + def __init__(self, world: "CVCotMWorld") -> None: + self.player = world.player + self.world = world + self.required_last_keys = world.required_last_keys + self.iron_maiden_behavior = world.options.iron_maiden_behavior.value + self.nerf_roc_wing = world.options.nerf_roc_wing.value + self.ignore_cleansing = world.options.ignore_cleansing.value + self.completion_goal = world.options.completion_goal.value + + self.location_rules = { + # Sealed Room + lname.sr3: self.has_jump_level_5, + # Catacomb + lname.cc1: self.has_push, + lname.cc3: self.has_jump_level_1, + lname.cc3b: lambda state: + (self.has_jump_level_1(state) and self.has_ice_or_stone(state)) or self.has_jump_level_4(state), + lname.cc5: self.has_tackle, + lname.cc8b: lambda state: self.has_jump_level_3(state) or self.has_kick(state), + lname.cc14b: lambda state: self.has_jump_level_1(state) or self.has_kick(state), + lname.cc25: self.has_jump_level_1, + # Abyss Staircase + lname.as4: self.has_jump_level_4, + # Audience Room + lname.ar9: self.has_push, + lname.ar11: self.has_tackle, + lname.ar14b: self.has_jump_level_4, + lname.ar17b: lambda state: self.has_jump_level_2(state) or self.has_kick(state), + lname.ar19: lambda state: self.has_jump_level_2(state) or self.has_kick(state), + lname.ar26: lambda state: self.has_tackle(state) and self.has_jump_level_5(state), + lname.ar27: lambda state: self.has_tackle(state) and self.has_push(state), + lname.ar30: lambda state: + (self.has_jump_level_3(state) and self.has_ice_or_stone(state)) or self.has_jump_level_4(state), + lname.ar30b: lambda state: + (self.has_jump_level_3(state) and self.has_ice_or_stone(state)) or self.has_jump_level_4(state), + # Outer Wall + lname.ow0: self.has_jump_level_4, + lname.ow1: lambda state: self.has_jump_level_5(state) or self.has_ice_or_stone(state), + # Triumph Hallway + lname.th3: lambda state: + (self.has_kick(state) and self.has_ice_or_stone(state)) or self.has_jump_level_2(state), + # Machine Tower + lname.mt3: lambda state: self.has_jump_level_2(state) or self.has_kick(state), + lname.mt6: lambda state: self.has_jump_level_2(state) or self.has_kick(state), + lname.mt14: self.has_tackle, + # Chapel Tower + lname.ct1: lambda state: self.has_jump_level_2(state) or self.has_ice_or_stone(state), + lname.ct4: self.has_push, + lname.ct10: self.has_push, + lname.ct13: lambda state: self.has_jump_level_2(state) or self.has_ice_or_stone(state), + lname.ct22: self.broke_iron_maidens, + lname.ct26: lambda state: + (self.has_jump_level_3(state) and self.has_ice_or_stone(state)) or self.has_jump_level_4(state), + lname.ct26b: lambda state: + (self.has_jump_level_3(state) and self.has_ice_or_stone(state)) or self.has_jump_level_4(state), + # Underground Gallery + lname.ug1: self.has_push, + lname.ug2: self.has_push, + lname.ug3: lambda state: self.has_jump_level_2(state) or self.has_ice_or_stone(state), + lname.ug3b: lambda state: self.has_jump_level_4(state) or self.has_ice_or_stone(state), + lname.ug8: self.has_tackle, + # Underground Warehouse + lname.uw10: lambda state: + (self.has_jump_level_4(state) and self.has_ice_or_stone(state)) or self.has_jump_level_5(state), + lname.uw14: lambda state: self.has_jump_level_2(state) or self.has_ice_or_stone(state), + lname.uw16b: lambda state: + (self.has_jump_level_2(state) and self.has_ice_or_stone(state)) or self.has_jump_level_3(state), + # Underground Waterway + lname.uy5: lambda state: self.has_jump_level_3(state) or self.has_ice_or_stone(state), + lname.uy8: self.has_jump_level_2, + lname.uy12b: self.can_touch_water, + lname.uy17: self.can_touch_water, + lname.uy13: self.has_jump_level_3, + lname.uy18: self.has_jump_level_3, + # Ceremonial Room + lname.cr1: lambda state: self.has_jump_level_2(state) or self.has_kick(state), + lname.dracula: self.has_jump_level_2, + } + + self.entrance_rules = { + "Catacomb to Stairway": lambda state: self.has_jump_level_1(state) or self.has_kick(state), + "Stairway to Audience": self.has_jump_level_1, + "Audience to Machine Bottom": self.has_tackle, + "Audience to Machine Top": lambda state: self.has_jump_level_2(state) or self.has_kick(state), + "Audience to Chapel": lambda state: + (self.has_jump_level_2(state) and self.has_ice_or_stone(state)) or self.has_jump_level_3(state) + or self.has_kick(state), + "Audience to Gallery": lambda state: self.broke_iron_maidens(state) and self.has_push(state), + "Audience to Warehouse": self.has_push, + "Audience to Waterway": self.broke_iron_maidens, + "Audience to Observation": self.has_jump_level_5, + "Ceremonial Door": self.can_open_ceremonial_door, + "Corridor to Gallery": self.broke_iron_maidens, + "Escape the Gallery Pit": lambda state: self.has_jump_level_2(state) or self.has_kick(state), + "Climb to Chapel Top": lambda state: self.has_jump_level_3(state) or self.has_kick(state), + "Arena Passage": lambda state: self.has_push(state) and self.has_jump_level_2(state), + "Dip Into Waterway End": self.has_jump_level_3, + "Gallery Upper to Lower": self.has_tackle, + "Gallery Lower to Upper": self.has_tackle, + "Into Warehouse Main": self.has_tackle, + "Into Waterway Main": self.can_touch_water, + } + + def has_jump_level_1(self, state: CollectionState) -> bool: + """Double or Roc Wing, regardless of Roc being nerfed or not.""" + return state.has_any([iname.double, iname.roc_wing], self.player) + + def has_jump_level_2(self, state: CollectionState) -> bool: + """Specifically Roc Wing, regardless of Roc being nerfed or not.""" + return state.has(iname.roc_wing, self.player) + + def has_jump_level_3(self, state: CollectionState) -> bool: + """Roc Wing and Double OR Kick Boots if Roc is nerfed. Otherwise, just Roc.""" + if self.nerf_roc_wing: + return state.has(iname.roc_wing, self.player) and \ + state.has_any([iname.double, iname.kick_boots], self.player) + else: + return state.has(iname.roc_wing, self.player) + + def has_jump_level_4(self, state: CollectionState) -> bool: + """Roc Wing and Kick Boots specifically if Roc is nerfed. Otherwise, just Roc.""" + if self.nerf_roc_wing: + return state.has_all([iname.roc_wing, iname.kick_boots], self.player) + else: + return state.has(iname.roc_wing, self.player) + + def has_jump_level_5(self, state: CollectionState) -> bool: + """Roc Wing, Double, AND Kick Boots if Roc is nerfed. Otherwise, just Roc.""" + if self.nerf_roc_wing: + return state.has_all([iname.roc_wing, iname.double, iname.kick_boots], self.player) + else: + return state.has(iname.roc_wing, self.player) + + def has_tackle(self, state: CollectionState) -> bool: + return state.has(iname.tackle, self.player) + + def has_push(self, state: CollectionState) -> bool: + return state.has(iname.heavy_ring, self.player) + + def has_kick(self, state: CollectionState) -> bool: + return state.has(iname.kick_boots, self.player) + + def has_ice_or_stone(self, state: CollectionState) -> bool: + """Valid DSS combo that allows freezing or petrifying enemies to use as platforms.""" + return state.has_any([iname.serpent, iname.cockatrice], self.player) and \ + state.has_any([iname.mercury, iname.mars], self.player) + + def can_touch_water(self, state: CollectionState) -> bool: + """Cleansing unless it's ignored, in which case this will always return True.""" + return self.ignore_cleansing or state.has(iname.cleansing, self.player) + + def broke_iron_maidens(self, state: CollectionState) -> bool: + """Maiden Detonator unless the Iron Maidens start broken, in which case this will always return True.""" + return (self.iron_maiden_behavior == IronMaidenBehavior.option_start_broken + or state.has(iname.ironmaidens, self.player)) + + def can_open_ceremonial_door(self, state: CollectionState) -> bool: + """The required number of Last Keys. If 0 keys are required, this should always return True.""" + return state.has(iname.last_key, self.player, self.required_last_keys) + + def set_cvcotm_rules(self) -> None: + multiworld = self.world.multiworld + + for region in multiworld.get_regions(self.player): + # Set each Entrance's rule if it should have one. + for ent in region.entrances: + if ent.name in self.entrance_rules: + ent.access_rule = self.entrance_rules[ent.name] + + # Set each Location's rule if it should have one. + for loc in region.locations: + if loc.name in self.location_rules: + loc.access_rule = self.location_rules[loc.name] + + # Set the World's completion condition depending on what its Completion Goal option is. + if self.completion_goal == CompletionGoal.option_dracula: + multiworld.completion_condition[self.player] = lambda state: state.has(iname.dracula, self.player) + elif self.completion_goal == CompletionGoal.option_battle_arena: + multiworld.completion_condition[self.player] = lambda state: state.has(iname.shinning_armor, self.player) + else: + multiworld.completion_condition[self.player] = \ + lambda state: state.has_all([iname.dracula, iname.shinning_armor], self.player) diff --git a/worlds/cvcotm/test/__init__.py b/worlds/cvcotm/test/__init__.py new file mode 100644 index 000000000000..d8092a937924 --- /dev/null +++ b/worlds/cvcotm/test/__init__.py @@ -0,0 +1,5 @@ +from test.bases import WorldTestBase + + +class CVCotMTestBase(WorldTestBase): + game = "Castlevania - Circle of the Moon" diff --git a/worlds/cvcotm/test/test_access.py b/worlds/cvcotm/test/test_access.py new file mode 100644 index 000000000000..7fba9964e112 --- /dev/null +++ b/worlds/cvcotm/test/test_access.py @@ -0,0 +1,811 @@ +from . import CVCotMTestBase +from ..data import iname, lname +from ..options import IronMaidenBehavior + + +class CatacombSphere1Test(CVCotMTestBase): + + def test_always_accessible(self) -> None: + self.assertTrue(self.can_reach_location(lname.cc4)) + self.assertTrue(self.can_reach_location(lname.cc8)) + self.assertTrue(self.can_reach_location(lname.cc9)) + self.assertTrue(self.can_reach_location(lname.cc10)) + self.assertTrue(self.can_reach_location(lname.cc13)) + self.assertTrue(self.can_reach_location(lname.cc14)) + self.assertTrue(self.can_reach_location(lname.cc16)) + self.assertTrue(self.can_reach_location(lname.cc20)) + self.assertTrue(self.can_reach_location(lname.cc22)) + self.assertTrue(self.can_reach_location(lname.cc24)) + + +class DoubleTest(CVCotMTestBase): + options = { + "iron_maiden_behavior": IronMaidenBehavior.option_start_broken, + "nerf_roc_wing": True, + "ignore_cleansing": True + } + + def test_double_only(self) -> None: + self.assertFalse(self.can_reach_location(lname.cc3)) + self.assertFalse(self.can_reach_location(lname.cc3b)) + self.assertFalse(self.can_reach_location(lname.cc14b)) + self.assertFalse(self.can_reach_location(lname.cc25)) + self.assertFalse(self.can_reach_entrance("Catacomb to Stairway")) + self.assertFalse(self.can_reach_entrance("Stairway to Audience")) + + self.collect_by_name([iname.double]) + + self.assertTrue(self.can_reach_location(lname.cc3)) + self.assertTrue(self.can_reach_location(lname.cc14b)) + self.assertTrue(self.can_reach_location(lname.cc25)) + self.assertTrue(self.can_reach_entrance("Catacomb to Stairway")) + self.assertTrue(self.can_reach_entrance("Stairway to Audience")) + + # Jump-locked things that Double still shouldn't be able to reach. + self.assertFalse(self.can_reach_location(lname.sr3)) + self.assertFalse(self.can_reach_location(lname.cc3b)) + self.assertFalse(self.can_reach_location(lname.cc8b)) + self.assertFalse(self.can_reach_location(lname.as4)) + self.assertFalse(self.can_reach_location(lname.ar14b)) + self.assertFalse(self.can_reach_location(lname.ar17b)) + self.assertFalse(self.can_reach_location(lname.ar19)) + self.assertFalse(self.can_reach_location(lname.ar26)) + self.assertFalse(self.can_reach_location(lname.ar30)) + self.assertFalse(self.can_reach_location(lname.ar30b)) + self.assertFalse(self.can_reach_location(lname.ow0)) + self.assertFalse(self.can_reach_location(lname.ow1)) + self.assertFalse(self.can_reach_location(lname.th3)) + self.assertFalse(self.can_reach_entrance("Audience to Machine Top")) + self.assertFalse(self.can_reach_entrance("Audience to Chapel")) + self.assertFalse(self.can_reach_entrance("Audience to Observation")) + + self.collect_by_name([iname.heavy_ring, iname.tackle]) + + self.assertFalse(self.can_reach_entrance("Escape the Gallery Pit")) + + def test_double_with_freeze(self) -> None: + self.collect_by_name([iname.mercury, iname.serpent]) + self.assertFalse(self.can_reach_location(lname.cc3b)) + + self.collect_by_name([iname.double]) + + self.assertTrue(self.can_reach_location(lname.cc3b)) + + def test_nerfed_roc_double_path(self) -> None: + self.collect_by_name([iname.roc_wing, iname.tackle, iname.heavy_ring]) + + self.assertFalse(self.can_reach_entrance("Audience to Chapel")) + self.assertFalse(self.can_reach_entrance("Arena Passage")) + self.assertFalse(self.can_reach_entrance("Dip Into Waterway End")) + self.assertFalse(self.can_reach_entrance("Climb to Chapel Top")) + self.assertFalse(self.can_reach_location(lname.cc8b)) + self.assertFalse(self.can_reach_location(lname.cc3b)) + self.assertFalse(self.can_reach_location(lname.as4)) + self.assertFalse(self.can_reach_location(lname.ar14b)) + self.assertFalse(self.can_reach_location(lname.ar30)) + self.assertFalse(self.can_reach_location(lname.ar30b)) + self.assertFalse(self.can_reach_location(lname.ow0)) + self.assertFalse(self.can_reach_location(lname.ct26)) + self.assertFalse(self.can_reach_location(lname.ct26b)) + self.assertFalse(self.can_reach_location(lname.ug3b)) + self.assertFalse(self.can_reach_location(lname.uw10)) + self.assertFalse(self.can_reach_location(lname.uw16b)) + self.assertFalse(self.can_reach_location(lname.uy5)) + self.assertFalse(self.can_reach_location(lname.uy13)) + self.assertFalse(self.can_reach_location(lname.uy18)) + + self.collect_by_name([iname.double]) + + self.assertTrue(self.can_reach_entrance("Audience to Chapel")) + self.assertTrue(self.can_reach_entrance("Arena Passage")) + self.assertTrue(self.can_reach_entrance("Dip Into Waterway End")) + self.assertTrue(self.can_reach_entrance("Climb to Chapel Top")) + self.assertTrue(self.can_reach_location(lname.cc8b)) + self.assertFalse(self.can_reach_location(lname.cc3b)) + self.assertFalse(self.can_reach_location(lname.as4)) + self.assertFalse(self.can_reach_location(lname.ar14b)) + self.assertFalse(self.can_reach_location(lname.ar30)) + self.assertFalse(self.can_reach_location(lname.ar30b)) + self.assertFalse(self.can_reach_location(lname.ow0)) + self.assertFalse(self.can_reach_location(lname.ct26)) + self.assertFalse(self.can_reach_location(lname.ct26b)) + self.assertFalse(self.can_reach_location(lname.ug3b)) + self.assertFalse(self.can_reach_location(lname.uw10)) + self.assertTrue(self.can_reach_location(lname.uw16b)) + self.assertTrue(self.can_reach_location(lname.uy5)) + self.assertTrue(self.can_reach_location(lname.uy13)) + self.assertTrue(self.can_reach_location(lname.uy18)) + + self.assertFalse(self.can_reach_entrance("Audience to Observation")) + self.assertFalse(self.can_reach_location(lname.sr3)) + self.assertFalse(self.can_reach_location(lname.ar26)) + self.assertFalse(self.can_reach_location(lname.ow1)) + + self.collect_by_name([iname.kick_boots]) + + self.assertTrue(self.can_reach_entrance("Arena Passage")) + self.assertTrue(self.can_reach_location(lname.cc3b)) + self.assertTrue(self.can_reach_location(lname.as4)) + self.assertTrue(self.can_reach_location(lname.ar14b)) + self.assertTrue(self.can_reach_location(lname.ar30)) + self.assertTrue(self.can_reach_location(lname.ar30b)) + self.assertTrue(self.can_reach_location(lname.ow0)) + self.assertTrue(self.can_reach_location(lname.ct26)) + self.assertTrue(self.can_reach_location(lname.ct26b)) + self.assertTrue(self.can_reach_location(lname.ug3b)) + self.assertTrue(self.can_reach_location(lname.uw10)) + self.assertTrue(self.can_reach_entrance("Audience to Observation")) + self.assertTrue(self.can_reach_location(lname.sr3)) + self.assertTrue(self.can_reach_location(lname.ar26)) + self.assertTrue(self.can_reach_location(lname.ow1)) + + +class TackleTest(CVCotMTestBase): + options = { + "iron_maiden_behavior": IronMaidenBehavior.option_start_broken, + } + + def test_tackle_only_in_catacomb(self) -> None: + self.assertFalse(self.can_reach_location(lname.cc5)) + + self.collect_by_name([iname.tackle]) + + self.assertTrue(self.can_reach_location(lname.cc5)) + + def test_tackle_only_in_audience_room(self) -> None: + self.collect_by_name([iname.double]) + + self.assertFalse(self.can_reach_location(lname.ar11)) + self.assertFalse(self.can_reach_entrance("Audience to Machine Bottom")) + + self.collect_by_name([iname.tackle]) + + self.assertTrue(self.can_reach_location(lname.ar11)) + self.assertTrue(self.can_reach_entrance("Audience to Machine Bottom")) + + def test_tackle_with_kick_boots(self) -> None: + self.collect_by_name([iname.double, iname.kick_boots]) + + self.assertFalse(self.can_reach_location(lname.mt14)) + self.assertFalse(self.can_reach_entrance("Gallery Upper to Lower")) + + self.collect_by_name([iname.tackle]) + + self.assertTrue(self.can_reach_location(lname.mt14)) + self.assertTrue(self.can_reach_entrance("Gallery Upper to Lower")) + + def test_tackle_with_heavy_ring(self) -> None: + self.collect_by_name([iname.double, iname.heavy_ring]) + + self.assertFalse(self.can_reach_location(lname.ar27)) + self.assertFalse(self.can_reach_location(lname.ug8)) + self.assertFalse(self.can_reach_entrance("Into Warehouse Main")) + self.assertFalse(self.can_reach_entrance("Gallery Lower to Upper")) + + self.collect_by_name([iname.tackle]) + + self.assertTrue(self.can_reach_location(lname.ar27)) + self.assertTrue(self.can_reach_location(lname.ug8)) + self.assertTrue(self.can_reach_entrance("Into Warehouse Main")) + self.assertTrue(self.can_reach_entrance("Gallery Lower to Upper")) + + def test_tackle_with_roc_wing(self) -> None: + self.collect_by_name([iname.roc_wing]) + + self.assertFalse(self.can_reach_location(lname.ar26)) + + self.collect_by_name([iname.tackle]) + + self.assertTrue(self.can_reach_location(lname.ar26)) + + +class KickBootsTest(CVCotMTestBase): + options = { + "iron_maiden_behavior": IronMaidenBehavior.option_start_broken, + "nerf_roc_wing": True, + "ignore_cleansing": True, + } + + def test_kick_boots_only_in_catacomb(self) -> None: + self.assertFalse(self.can_reach_location(lname.cc8b)) + self.assertFalse(self.can_reach_location(lname.cc14b)) + self.assertFalse(self.can_reach_entrance("Catacomb to Stairway")) + + self.collect_by_name([iname.kick_boots]) + + self.assertTrue(self.can_reach_location(lname.cc8b)) + self.assertTrue(self.can_reach_location(lname.cc14b)) + self.assertTrue(self.can_reach_entrance("Catacomb to Stairway")) + + def test_kick_boots_only_in_audience_room(self) -> None: + self.collect_by_name([iname.double]) + + self.assertFalse(self.can_reach_location(lname.ar17b)) + self.assertFalse(self.can_reach_location(lname.ar19)) + self.assertFalse(self.can_reach_entrance("Audience to Machine Top")) + self.assertFalse(self.can_reach_entrance("Audience to Chapel")) + self.assertFalse(self.can_reach_entrance("Climb to Chapel Top")) + + self.collect_by_name([iname.kick_boots]) + + self.assertTrue(self.can_reach_location(lname.ar17b)) + self.assertTrue(self.can_reach_location(lname.ar19)) + self.assertTrue(self.can_reach_entrance("Audience to Machine Top")) + self.assertTrue(self.can_reach_entrance("Audience to Chapel")) + self.assertTrue(self.can_reach_entrance("Climb to Chapel Top")) + + def test_kick_boots_with_tackle(self) -> None: + self.collect_by_name([iname.double, iname.tackle]) + + self.assertFalse(self.can_reach_location(lname.mt3)) + self.assertFalse(self.can_reach_location(lname.mt6)) + + self.collect_by_name([iname.kick_boots]) + + self.assertTrue(self.can_reach_location(lname.mt3)) + self.assertTrue(self.can_reach_location(lname.mt6)) + + def test_kick_boots_with_freeze(self) -> None: + self.collect_by_name([iname.double, iname.mars, iname.cockatrice]) + + self.assertFalse(self.can_reach_region("Underground Gallery Upper")) + self.assertFalse(self.can_reach_location(lname.th3)) + self.assertFalse(self.can_reach_location(lname.ug3)) + self.assertFalse(self.can_reach_location(lname.ug3b)) + + self.collect_by_name([iname.kick_boots]) + + self.assertTrue(self.can_reach_region("Underground Gallery Upper")) + self.assertTrue(self.can_reach_location(lname.th3)) + self.assertTrue(self.can_reach_location(lname.ug3)) + self.assertTrue(self.can_reach_location(lname.ug3b)) + + def test_kick_boots_with_last_key(self) -> None: + self.collect_by_name([iname.double, iname.last_key]) + + self.assertFalse(self.can_reach_location(lname.cr1)) + + self.collect_by_name([iname.kick_boots]) + + self.assertTrue(self.can_reach_location(lname.cr1)) + + def test_nerfed_roc_kick_boots_path(self) -> None: + self.collect_by_name([iname.roc_wing, iname.tackle, iname.heavy_ring]) + + self.assertFalse(self.can_reach_entrance("Audience to Chapel")) + self.assertFalse(self.can_reach_entrance("Arena Passage")) + self.assertFalse(self.can_reach_entrance("Dip Into Waterway End")) + self.assertFalse(self.can_reach_entrance("Climb to Chapel Top")) + self.assertFalse(self.can_reach_location(lname.cc8b)) + self.assertFalse(self.can_reach_location(lname.cc3b)) + self.assertFalse(self.can_reach_location(lname.as4)) + self.assertFalse(self.can_reach_location(lname.ar14b)) + self.assertFalse(self.can_reach_location(lname.ar30)) + self.assertFalse(self.can_reach_location(lname.ar30b)) + self.assertFalse(self.can_reach_location(lname.ow0)) + self.assertFalse(self.can_reach_location(lname.ct26)) + self.assertFalse(self.can_reach_location(lname.ct26b)) + self.assertFalse(self.can_reach_location(lname.ug3b)) + self.assertFalse(self.can_reach_location(lname.uw10)) + self.assertFalse(self.can_reach_location(lname.uw16b)) + self.assertFalse(self.can_reach_location(lname.uy5)) + self.assertFalse(self.can_reach_location(lname.uy13)) + self.assertFalse(self.can_reach_location(lname.uy18)) + + self.collect_by_name([iname.kick_boots]) + + self.assertTrue(self.can_reach_entrance("Audience to Chapel")) + self.assertTrue(self.can_reach_entrance("Arena Passage")) + self.assertTrue(self.can_reach_entrance("Dip Into Waterway End")) + self.assertTrue(self.can_reach_entrance("Climb to Chapel Top")) + self.assertTrue(self.can_reach_location(lname.cc8b)) + self.assertTrue(self.can_reach_location(lname.cc3b)) + self.assertTrue(self.can_reach_location(lname.as4)) + self.assertTrue(self.can_reach_location(lname.ar14b)) + self.assertTrue(self.can_reach_location(lname.ar30)) + self.assertTrue(self.can_reach_location(lname.ar30b)) + self.assertTrue(self.can_reach_location(lname.ow0)) + self.assertTrue(self.can_reach_location(lname.ct26)) + self.assertTrue(self.can_reach_location(lname.ct26b)) + self.assertTrue(self.can_reach_location(lname.ug3b)) + self.assertFalse(self.can_reach_location(lname.uw10)) + self.assertTrue(self.can_reach_location(lname.uw16b)) + self.assertTrue(self.can_reach_location(lname.uy5)) + self.assertTrue(self.can_reach_location(lname.uy13)) + self.assertTrue(self.can_reach_location(lname.uy18)) + + self.assertFalse(self.can_reach_entrance("Audience to Observation")) + self.assertFalse(self.can_reach_location(lname.sr3)) + self.assertFalse(self.can_reach_location(lname.ar26)) + self.assertFalse(self.can_reach_location(lname.ow1)) + + self.collect_by_name([iname.double]) + + self.assertTrue(self.can_reach_location(lname.uw10)) + self.assertTrue(self.can_reach_entrance("Audience to Observation")) + self.assertTrue(self.can_reach_location(lname.sr3)) + self.assertTrue(self.can_reach_location(lname.ar26)) + self.assertTrue(self.can_reach_location(lname.ow1)) + + +class HeavyRingTest(CVCotMTestBase): + options = { + "iron_maiden_behavior": IronMaidenBehavior.option_start_broken + } + + def test_heavy_ring_only_in_catacomb(self) -> None: + self.assertFalse(self.can_reach_location(lname.cc1)) + + self.collect_by_name([iname.heavy_ring]) + + self.assertTrue(self.can_reach_location(lname.cc1)) + + def test_heavy_ring_only_in_audience_room(self) -> None: + self.collect_by_name([iname.double]) + + self.assertFalse(self.can_reach_location(lname.ar9)) + self.assertFalse(self.can_reach_entrance("Audience to Gallery")) + self.assertFalse(self.can_reach_entrance("Audience to Warehouse")) + + self.collect_by_name([iname.heavy_ring]) + + self.assertTrue(self.can_reach_location(lname.ar9)) + self.assertTrue(self.can_reach_entrance("Audience to Gallery")) + self.assertTrue(self.can_reach_entrance("Audience to Warehouse")) + + def test_heavy_ring_with_tackle(self) -> None: + self.collect_by_name([iname.double, iname.tackle]) + + self.assertFalse(self.can_reach_location(lname.ar27)) + self.assertFalse(self.can_reach_entrance("Into Warehouse Main")) + + self.collect_by_name([iname.heavy_ring]) + + self.assertTrue(self.can_reach_location(lname.ar27)) + self.assertTrue(self.can_reach_entrance("Into Warehouse Main")) + + def test_heavy_ring_with_kick_boots(self) -> None: + self.collect_by_name([iname.double, iname.kick_boots]) + + self.assertFalse(self.can_reach_location(lname.ct4)) + self.assertFalse(self.can_reach_location(lname.ct10)) + self.assertFalse(self.can_reach_location(lname.ug1)) + self.assertFalse(self.can_reach_location(lname.ug2)) + + self.collect_by_name([iname.heavy_ring]) + + self.assertTrue(self.can_reach_location(lname.ct4)) + self.assertTrue(self.can_reach_location(lname.ct10)) + self.assertTrue(self.can_reach_location(lname.ug1)) + self.assertTrue(self.can_reach_location(lname.ug2)) + + def test_heavy_ring_with_roc_wing(self) -> None: + self.collect_by_name([iname.roc_wing]) + + self.assertFalse(self.can_reach_entrance("Arena Passage")) + + self.collect_by_name([iname.heavy_ring]) + + self.assertTrue(self.can_reach_entrance("Arena Passage")) + + +class CleansingTest(CVCotMTestBase): + options = { + "iron_maiden_behavior": IronMaidenBehavior.option_start_broken + } + + def test_cleansing_only(self) -> None: + self.collect_by_name([iname.double]) + + self.assertFalse(self.can_reach_entrance("Into Waterway Main")) + + self.collect_by_name([iname.cleansing]) + + self.assertTrue(self.can_reach_entrance("Into Waterway Main")) + + def test_cleansing_with_roc(self) -> None: + self.collect_by_name([iname.roc_wing]) + + self.assertFalse(self.can_reach_location(lname.uy12b)) + self.assertFalse(self.can_reach_location(lname.uy17)) + + self.assertTrue(self.can_reach_entrance("Dip Into Waterway End")) + + self.collect_by_name([iname.cleansing]) + + self.assertTrue(self.can_reach_location(lname.uy12b)) + self.assertTrue(self.can_reach_location(lname.uy17)) + + +class IgnoredCleansingTest(CVCotMTestBase): + options = { + "iron_maiden_behavior": IronMaidenBehavior.option_start_broken, + "ignore_cleansing": True + } + + def test_ignored_cleansing(self) -> None: + self.assertFalse(self.can_reach_entrance("Into Waterway Main")) + self.assertFalse(self.can_reach_location(lname.uy12b)) + self.assertFalse(self.can_reach_location(lname.uy17)) + + self.collect_by_name([iname.double]) + + self.assertTrue(self.can_reach_entrance("Into Waterway Main")) + self.assertTrue(self.can_reach_location(lname.uy12b)) + self.assertTrue(self.can_reach_location(lname.uy17)) + + +class UnNerfedRocTest(CVCotMTestBase): + options = { + "iron_maiden_behavior": IronMaidenBehavior.option_start_broken + } + + def test_roc_wing_only(self) -> None: + self.assertFalse(self.can_reach_location(lname.sr3)) + self.assertFalse(self.can_reach_location(lname.cc3)) + self.assertFalse(self.can_reach_location(lname.cc3b)) + self.assertFalse(self.can_reach_location(lname.cc8b)) + self.assertFalse(self.can_reach_location(lname.cc14b)) + self.assertFalse(self.can_reach_location(lname.cc25)) + self.assertFalse(self.can_reach_location(lname.as4)) + self.assertFalse(self.can_reach_location(lname.ar14b)) + self.assertFalse(self.can_reach_location(lname.ar17b)) + self.assertFalse(self.can_reach_location(lname.ar19)) + self.assertFalse(self.can_reach_location(lname.ar30)) + self.assertFalse(self.can_reach_location(lname.ar30b)) + self.assertFalse(self.can_reach_location(lname.ow0)) + self.assertFalse(self.can_reach_location(lname.ow1)) + self.assertFalse(self.can_reach_location(lname.th3)) + self.assertFalse(self.can_reach_location(lname.ct1)) + self.assertFalse(self.can_reach_location(lname.ct13)) + self.assertFalse(self.can_reach_location(lname.ug3)) + self.assertFalse(self.can_reach_location(lname.ug3b)) + self.assertFalse(self.can_reach_entrance("Catacomb to Stairway")) + self.assertFalse(self.can_reach_entrance("Stairway to Audience")) + self.assertFalse(self.can_reach_entrance("Audience to Machine Top")) + self.assertFalse(self.can_reach_entrance("Audience to Chapel")) + self.assertFalse(self.can_reach_entrance("Audience to Observation")) + self.assertFalse(self.can_reach_entrance("Dip Into Waterway End")) + + self.collect_by_name([iname.roc_wing]) + + self.assertTrue(self.can_reach_location(lname.sr3)) + self.assertTrue(self.can_reach_location(lname.cc3)) + self.assertTrue(self.can_reach_location(lname.cc3b)) + self.assertTrue(self.can_reach_location(lname.cc8b)) + self.assertTrue(self.can_reach_location(lname.cc14b)) + self.assertTrue(self.can_reach_location(lname.cc25)) + self.assertTrue(self.can_reach_location(lname.as4)) + self.assertTrue(self.can_reach_location(lname.ar14b)) + self.assertTrue(self.can_reach_location(lname.ar17b)) + self.assertTrue(self.can_reach_location(lname.ar19)) + self.assertTrue(self.can_reach_location(lname.ar30)) + self.assertTrue(self.can_reach_location(lname.ar30b)) + self.assertTrue(self.can_reach_location(lname.ow0)) + self.assertTrue(self.can_reach_location(lname.ow1)) + self.assertTrue(self.can_reach_location(lname.th3)) + self.assertTrue(self.can_reach_location(lname.ct1)) + self.assertTrue(self.can_reach_location(lname.ct13)) + self.assertTrue(self.can_reach_location(lname.ug3)) + self.assertTrue(self.can_reach_location(lname.ug3b)) + self.assertTrue(self.can_reach_entrance("Catacomb to Stairway")) + self.assertTrue(self.can_reach_entrance("Stairway to Audience")) + self.assertTrue(self.can_reach_entrance("Audience to Machine Top")) + self.assertTrue(self.can_reach_entrance("Audience to Chapel")) + self.assertTrue(self.can_reach_entrance("Audience to Observation")) + self.assertTrue(self.can_reach_entrance("Dip Into Waterway End")) + self.assertFalse(self.can_reach_entrance("Arena Passage")) + + def test_roc_wing_exclusive_accessibility(self) -> None: + self.collect_by_name([iname.double, iname.tackle, iname.kick_boots, iname.heavy_ring, iname.cleansing, + iname.last_key, iname.mercury, iname.cockatrice]) + + self.assertFalse(self.can_reach_location(lname.sr3)) + self.assertFalse(self.can_reach_location(lname.as4)) + self.assertFalse(self.can_reach_location(lname.ar14b)) + self.assertFalse(self.can_reach_location(lname.ar26)) + self.assertFalse(self.can_reach_location(lname.ar30)) + self.assertFalse(self.can_reach_location(lname.ar30b)) + self.assertFalse(self.can_reach_location(lname.ow0)) + self.assertFalse(self.can_reach_location(lname.uw10)) + self.assertFalse(self.can_reach_location(lname.uw16b)) + self.assertFalse(self.can_reach_location(lname.uy8)) + self.assertFalse(self.can_reach_location(lname.uy13)) + self.assertFalse(self.can_reach_location(lname.uy18)) + self.assertFalse(self.can_reach_location(lname.dracula)) + self.assertFalse(self.can_reach_entrance("Audience to Observation")) + self.assertFalse(self.can_reach_entrance("Arena Passage")) + self.assertFalse(self.can_reach_entrance("Dip Into Waterway End")) + + self.collect_by_name([iname.roc_wing]) + + self.assertTrue(self.can_reach_location(lname.sr3)) + self.assertTrue(self.can_reach_location(lname.as4)) + self.assertTrue(self.can_reach_location(lname.ar14b)) + self.assertTrue(self.can_reach_location(lname.ar26)) + self.assertTrue(self.can_reach_location(lname.ar30)) + self.assertTrue(self.can_reach_location(lname.ar30b)) + self.assertTrue(self.can_reach_location(lname.ow0)) + self.assertTrue(self.can_reach_location(lname.uw10)) + self.assertTrue(self.can_reach_location(lname.uw16b)) + self.assertTrue(self.can_reach_location(lname.uy8)) + self.assertTrue(self.can_reach_location(lname.uy13)) + self.assertTrue(self.can_reach_location(lname.uy18)) + self.assertTrue(self.can_reach_location(lname.dracula)) + self.assertTrue(self.can_reach_entrance("Audience to Observation")) + self.assertTrue(self.can_reach_entrance("Arena Passage")) + self.assertTrue(self.can_reach_entrance("Dip Into Waterway End")) + + +class NerfedRocTest(CVCotMTestBase): + options = { + "iron_maiden_behavior": IronMaidenBehavior.option_start_broken, + "nerf_roc_wing": True, + "ignore_cleansing": True + } + + def test_nerfed_roc_without_double_or_kick(self) -> None: + self.collect_by_name([iname.tackle, iname.heavy_ring, iname.last_key]) + self.assertFalse(self.can_reach_location(lname.cc3)) + self.assertFalse(self.can_reach_location(lname.cc3b)) + self.assertFalse(self.can_reach_location(lname.cc14b)) + self.assertFalse(self.can_reach_location(lname.cc25)) + self.assertFalse(self.can_reach_entrance("Catacomb to Stairway")) + self.assertFalse(self.can_reach_entrance("Stairway to Audience")) + + self.collect_by_name([iname.roc_wing]) + + # Jump-locked things inside Catacomb that just Roc Wing should be able to reach while nerfed. + self.assertTrue(self.can_reach_location(lname.cc3)) + self.assertTrue(self.can_reach_location(lname.cc14b)) + self.assertTrue(self.can_reach_location(lname.cc25)) + self.assertTrue(self.can_reach_entrance("Catacomb to Stairway")) + self.assertTrue(self.can_reach_entrance("Stairway to Audience")) + + # Jump-locked things outside Catacomb that just Roc Wing should be able to reach while nerfed. + self.assertTrue(self.can_reach_location(lname.ar17b)) + self.assertTrue(self.can_reach_location(lname.ar19)) + self.assertTrue(self.can_reach_location(lname.th3)) + self.assertTrue(self.can_reach_location(lname.mt3)) + self.assertTrue(self.can_reach_location(lname.mt6)) + self.assertTrue(self.can_reach_location(lname.ct1)) + self.assertTrue(self.can_reach_location(lname.ct13)) + self.assertTrue(self.can_reach_location(lname.ug3)) + self.assertTrue(self.can_reach_location(lname.uw14)) + self.assertTrue(self.can_reach_location(lname.uy8)) + self.assertTrue(self.can_reach_location(lname.cr1)) + self.assertTrue(self.can_reach_location(lname.dracula)) + self.assertTrue(self.can_reach_entrance("Audience to Machine Top")) + self.assertTrue(self.can_reach_entrance("Escape the Gallery Pit")) + + # Jump-locked things outside Catacomb that just Roc Wing shouldn't be able to reach while nerfed. + self.assertFalse(self.can_reach_location(lname.sr3)) + self.assertFalse(self.can_reach_location(lname.cc3b)) + self.assertFalse(self.can_reach_location(lname.cc8b)) + self.assertFalse(self.can_reach_location(lname.as4)) + self.assertFalse(self.can_reach_location(lname.ar14b)) + self.assertFalse(self.can_reach_location(lname.ar30)) + self.assertFalse(self.can_reach_location(lname.ar30b)) + self.assertFalse(self.can_reach_location(lname.ow0)) + self.assertFalse(self.can_reach_location(lname.ow1)) + self.assertFalse(self.can_reach_location(lname.ct26)) + self.assertFalse(self.can_reach_location(lname.ct26b)) + self.assertFalse(self.can_reach_location(lname.ug3b)) + self.assertFalse(self.can_reach_location(lname.uw10)) + self.assertFalse(self.can_reach_location(lname.uw16b)) + self.assertFalse(self.can_reach_location(lname.uy5)) + self.assertFalse(self.can_reach_location(lname.uy13)) + self.assertFalse(self.can_reach_location(lname.uy18)) + self.assertFalse(self.can_reach_entrance("Audience to Chapel")) + self.assertFalse(self.can_reach_entrance("Audience to Observation")) + self.assertFalse(self.can_reach_entrance("Climb to Chapel Top")) + + self.collect_by_name([iname.double, iname.kick_boots]) + + self.assertTrue(self.can_reach_location(lname.sr3)) + self.assertTrue(self.can_reach_location(lname.cc3b)) + self.assertTrue(self.can_reach_location(lname.cc8b)) + self.assertTrue(self.can_reach_location(lname.as4)) + self.assertTrue(self.can_reach_location(lname.ar14b)) + self.assertTrue(self.can_reach_location(lname.ar30)) + self.assertTrue(self.can_reach_location(lname.ar30b)) + self.assertTrue(self.can_reach_location(lname.ow0)) + self.assertTrue(self.can_reach_location(lname.ow1)) + self.assertTrue(self.can_reach_location(lname.ct26)) + self.assertTrue(self.can_reach_location(lname.ct26b)) + self.assertTrue(self.can_reach_location(lname.ug3b)) + self.assertTrue(self.can_reach_location(lname.uw10)) + self.assertTrue(self.can_reach_location(lname.uw16b)) + self.assertTrue(self.can_reach_location(lname.uy5)) + self.assertTrue(self.can_reach_location(lname.uy13)) + self.assertTrue(self.can_reach_location(lname.uy18)) + self.assertTrue(self.can_reach_entrance("Audience to Chapel")) + self.assertTrue(self.can_reach_entrance("Audience to Observation")) + self.assertTrue(self.can_reach_entrance("Climb to Chapel Top")) + + +class LastKeyTest(CVCotMTestBase): + options = { + "required_last_keys": 9, + "available_last_keys": 9 + } + + def test_last_keys(self) -> None: + self.collect_by_name([iname.double]) + + self.assertFalse(self.can_reach_entrance("Ceremonial Door")) + + self.collect([self.get_item_by_name(iname.last_key)] * 1) + + self.assertFalse(self.can_reach_entrance("Ceremonial Door")) + + self.collect([self.get_item_by_name(iname.last_key)] * 7) + + self.assertFalse(self.can_reach_entrance("Ceremonial Door")) + + self.collect([self.get_item_by_name(iname.last_key)] * 1) + + self.assertTrue(self.can_reach_entrance("Ceremonial Door")) + + +class FreezeTest(CVCotMTestBase): + options = { + "iron_maiden_behavior": IronMaidenBehavior.option_start_broken, + "nerf_roc_wing": True + } + + def test_freeze_only_in_audience_room(self) -> None: + self.collect_by_name([iname.double]) + + self.assertFalse(self.can_reach_location(lname.cc3b)) + self.assertFalse(self.can_reach_location(lname.ow1)) + + self.collect_by_name([iname.mars, iname.serpent]) + + self.assertTrue(self.can_reach_location(lname.cc3b)) + self.assertTrue(self.can_reach_location(lname.ow1)) + + def test_freeze_with_kick_boots(self) -> None: + self.collect_by_name([iname.double, iname.kick_boots]) + + self.assertFalse(self.can_reach_location(lname.th3)) + self.assertFalse(self.can_reach_location(lname.ct1)) + self.assertFalse(self.can_reach_location(lname.ct13)) + self.assertFalse(self.can_reach_location(lname.ug3)) + self.assertFalse(self.can_reach_location(lname.ug3b)) + + self.collect_by_name([iname.mercury, iname.serpent]) + + self.assertTrue(self.can_reach_location(lname.th3)) + self.assertTrue(self.can_reach_location(lname.ct1)) + self.assertTrue(self.can_reach_location(lname.ct13)) + self.assertTrue(self.can_reach_location(lname.ug3)) + self.assertTrue(self.can_reach_location(lname.ug3b)) + + def test_freeze_with_heavy_ring_and_tackle(self) -> None: + self.collect_by_name([iname.double, iname.heavy_ring, iname.tackle]) + + self.assertFalse(self.can_reach_location(lname.uw14)) + + self.collect_by_name([iname.mercury, iname.cockatrice]) + + self.assertTrue(self.can_reach_location(lname.uw14)) + + def test_freeze_with_cleansing(self) -> None: + self.collect_by_name([iname.double, iname.cleansing]) + + self.assertFalse(self.can_reach_location(lname.uy5)) + + self.collect_by_name([iname.mercury, iname.serpent]) + + self.assertTrue(self.can_reach_location(lname.uy5)) + + def test_freeze_with_nerfed_roc(self) -> None: + self.collect_by_name([iname.roc_wing, iname.heavy_ring, iname.tackle]) + + self.assertFalse(self.can_reach_entrance("Audience to Chapel")) + self.assertFalse(self.can_reach_location(lname.uw16b)) + + self.collect_by_name([iname.mercury, iname.cockatrice]) + + self.assertTrue(self.can_reach_entrance("Audience to Chapel")) + self.assertTrue(self.can_reach_location(lname.uw16b)) + + # Freeze spots requiring Double + self.assertFalse(self.can_reach_location(lname.ar30)) + self.assertFalse(self.can_reach_location(lname.ar30b)) + self.assertFalse(self.can_reach_location(lname.ct26)) + self.assertFalse(self.can_reach_location(lname.ct26b)) + self.assertFalse(self.can_reach_location(lname.uw10)) + + self.collect_by_name([iname.double]) + + self.assertTrue(self.can_reach_location(lname.ar30)) + self.assertTrue(self.can_reach_location(lname.ar30b)) + self.assertTrue(self.can_reach_location(lname.ct26)) + self.assertTrue(self.can_reach_location(lname.ct26b)) + self.assertFalse(self.can_reach_location(lname.uw10)) + + self.remove_by_name([iname.double]) + + # Freeze spots requiring Kick Boots + self.assertFalse(self.can_reach_location(lname.ar30)) + self.assertFalse(self.can_reach_location(lname.ar30b)) + self.assertFalse(self.can_reach_location(lname.ct26)) + self.assertFalse(self.can_reach_location(lname.ct26b)) + self.assertFalse(self.can_reach_location(lname.uw10)) + + self.collect_by_name([iname.kick_boots]) + + self.assertTrue(self.can_reach_location(lname.ar30)) + self.assertTrue(self.can_reach_location(lname.ar30b)) + self.assertTrue(self.can_reach_location(lname.ct26)) + self.assertTrue(self.can_reach_location(lname.ct26b)) + self.assertTrue(self.can_reach_location(lname.uw10)) + + def test_freeze_with_nerfed_roc_and_double(self) -> None: + self.collect_by_name([iname.roc_wing, iname.heavy_ring, iname.tackle, iname.double]) + + self.assertFalse(self.can_reach_location(lname.ar30)) + self.assertFalse(self.can_reach_location(lname.ar30b)) + self.assertFalse(self.can_reach_location(lname.ct26)) + self.assertFalse(self.can_reach_location(lname.ct26b)) + + self.collect_by_name([iname.mars, iname.cockatrice]) + + self.assertTrue(self.can_reach_location(lname.ar30)) + self.assertTrue(self.can_reach_location(lname.ar30b)) + self.assertTrue(self.can_reach_location(lname.ct26)) + self.assertTrue(self.can_reach_location(lname.ct26b)) + + def test_freeze_with_nerfed_roc_and_kick_boots(self) -> None: + self.collect_by_name([iname.roc_wing, iname.heavy_ring, iname.tackle, iname.kick_boots]) + + self.assertFalse(self.can_reach_location(lname.uw10)) + + self.collect_by_name([iname.mars, iname.serpent]) + + self.assertTrue(self.can_reach_location(lname.uw10)) + + +class VanillaMaidensTest(CVCotMTestBase): + + def test_waterway_and_right_gallery_maidens(self) -> None: + self.collect_by_name([iname.double]) + + self.assertFalse(self.can_reach_entrance("Audience to Waterway")) + self.assertFalse(self.can_reach_entrance("Corridor to Gallery")) + + # Gives access to Chapel Tower wherein we collect the locked Maiden Detonator item. + self.collect_by_name([iname.kick_boots]) + + self.assertTrue(self.can_reach_entrance("Audience to Waterway")) + self.assertTrue(self.can_reach_entrance("Corridor to Gallery")) + + def test_left_gallery_maiden(self) -> None: + self.collect_by_name([iname.double, iname.heavy_ring]) + + self.assertFalse(self.can_reach_entrance("Audience to Gallery")) + + self.collect_by_name([iname.roc_wing]) + + self.assertTrue(self.can_reach_entrance("Audience to Gallery")) + + +class MaidenDetonatorInPoolTest(CVCotMTestBase): + options = { + "iron_maiden_behavior": IronMaidenBehavior.option_detonator_in_pool + } + + def test_maiden_detonator(self) -> None: + self.collect_by_name([iname.double, iname.heavy_ring, iname.kick_boots]) + + self.assertFalse(self.can_reach_entrance("Audience to Waterway")) + self.assertFalse(self.can_reach_entrance("Corridor to Gallery")) + self.assertFalse(self.can_reach_entrance("Audience to Gallery")) + + self.collect_by_name([iname.ironmaidens]) + + self.assertTrue(self.can_reach_entrance("Audience to Waterway")) + self.assertTrue(self.can_reach_entrance("Corridor to Gallery")) + self.assertTrue(self.can_reach_entrance("Audience to Gallery")) From 144d612c527ac02eb290c8e7960c61c6b2fe1d79 Mon Sep 17 00:00:00 2001 From: josephwhite Date: Thu, 12 Dec 2024 08:50:48 -0500 Subject: [PATCH 021/144] Super Mario 64: Rework logic for 100 Coins (#4131) * sm64ex: Rework logic for 100 Coins * sm64ex: 100 Coins Vanilla Option * sm64ex: Avoiding raw int comparisons for 100 coin option * sm64ex: Change 100 coin option from toggle to choice * sm64ex: use snake_case for 100 coin option * just use "vanilla" for option comparison (exempt-medic feedback) Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * sm64ex: remove vanilla 100 coins from item pool to remove overfilling stars * yeah Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Remove range condition (35 is the min for total stars) Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- worlds/sm64ex/Options.py | 17 ++++++++++++++--- worlds/sm64ex/__init__.py | 23 ++++++++++++++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/worlds/sm64ex/Options.py b/worlds/sm64ex/Options.py index 6cf233558ce2..9c428c99590e 100644 --- a/worlds/sm64ex/Options.py +++ b/worlds/sm64ex/Options.py @@ -3,10 +3,21 @@ from Options import DefaultOnToggle, Range, Toggle, DeathLink, Choice, PerGameCommonOptions, OptionSet, OptionGroup from .Items import action_item_table -class EnableCoinStars(DefaultOnToggle): - """Disable to Ignore 100 Coin Stars. You can still collect them, but they don't do anything. - Removes 15 locations from the pool.""" +class EnableCoinStars(Choice): + """ + Determine logic for 100 Coin Stars. + + Off - Removed from pool. You can still collect them, but they don't do anything. + Optimal for ignoring 100 Coin Stars entirely. Removes 15 locations from the pool. + + On - Kept in pool, potentially randomized. + + Vanilla - Kept in pool, but NOT randomized. + """ display_name = "Enable 100 Coin Stars" + option_off = 0 + option_on = 1 + option_vanilla = 2 class StrictCapRequirements(DefaultOnToggle): diff --git a/worlds/sm64ex/__init__.py b/worlds/sm64ex/__init__.py index 40c778ebe66c..afa67f233c69 100644 --- a/worlds/sm64ex/__init__.py +++ b/worlds/sm64ex/__init__.py @@ -104,7 +104,11 @@ def create_items(self): # 1Up Mushrooms self.multiworld.itempool += [self.create_item("1Up Mushroom") for i in range(0,self.filler_count)] # Power Stars - self.multiworld.itempool += [self.create_item("Power Star") for i in range(0,self.number_of_stars)] + star_range = self.number_of_stars + # Vanilla 100 Coin stars have to removed from the pool if other max star increasing options are active. + if self.options.enable_coin_stars == "vanilla": + star_range -= 15 + self.multiworld.itempool += [self.create_item("Power Star") for i in range(0,star_range)] # Keys if (not self.options.progressive_keys): key1 = self.create_item("Basement Key") @@ -166,6 +170,23 @@ def generate_basic(self): self.multiworld.get_location("Wing Mario Over the Rainbow 1Up Block", self.player).place_locked_item(self.create_item("1Up Mushroom")) self.multiworld.get_location("Bowser in the Sky 1Up Block", self.player).place_locked_item(self.create_item("1Up Mushroom")) + if (self.options.enable_coin_stars == "vanilla"): + self.multiworld.get_location("BoB: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("WF: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("JRB: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("CCM: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("BBH: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("HMC: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("LLL: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("SSL: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("DDD: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("SL: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("WDW: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("TTM: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("THI: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("TTC: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + self.multiworld.get_location("RR: 100 Coins", self.player).place_locked_item(self.create_item("Power Star")) + def get_filler_item_name(self) -> str: return "1Up Mushroom" From f5e3677ef1ec741f830e6ce3d231a25dca7c2689 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Thu, 12 Dec 2024 18:04:27 +0000 Subject: [PATCH 022/144] Pokemon Emerald: Fix invalid escape sequence warnings (#4328) Generation on Python 3.12 would print SyntaxWarnings due to invalid '\d' escape sequences added in #3832. Use raw strings to avoid `\` being used to escape characters. --- worlds/pokemon_emerald/data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/pokemon_emerald/data.py b/worlds/pokemon_emerald/data.py index d93ff926229b..34bebae2d66a 100644 --- a/worlds/pokemon_emerald/data.py +++ b/worlds/pokemon_emerald/data.py @@ -397,13 +397,13 @@ def _init() -> None: label = [] for word in map_name[4:].split("_"): # 1F, B1F, 2R, etc. - re_match = re.match("^B?\d+[FRP]$", word) + re_match = re.match(r"^B?\d+[FRP]$", word) if re_match: label.append(word) continue # Route 103, Hall 1, House 5, etc. - re_match = re.match("^([A-Z]+)(\d+)$", word) + re_match = re.match(r"^([A-Z]+)(\d+)$", word) if re_match: label.append(re_match.group(1).capitalize()) label.append(re_match.group(2).lstrip("0")) From d7736950cd48d3df9ee35ff1167dc6586172903e Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Thu, 12 Dec 2024 19:42:14 +0100 Subject: [PATCH 023/144] The Witness: Panel Hunt Plando (#3549) * Add panel hunt plando option * Keys are strs * oops * better message * , * this doesn ot need to be here * don't replace pre picked panels * Update options.py * rebase error * rebase error * oops * Mypy * ruff * another rebase error * actually this is a stupid change too * bring over that change:tm: * Update entity_hunt.py * Update entity_hunt.py * Update entity_hunt.py --- worlds/witness/entity_hunt.py | 57 +++++++++++++++++++++++++++-------- worlds/witness/options.py | 14 +++++++++ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/worlds/witness/entity_hunt.py b/worlds/witness/entity_hunt.py index 86881930c3e1..9549246ce479 100644 --- a/worlds/witness/entity_hunt.py +++ b/worlds/witness/entity_hunt.py @@ -1,5 +1,5 @@ from collections import defaultdict -from logging import debug +from logging import debug, warning from pprint import pformat from typing import TYPE_CHECKING, Dict, List, Set, Tuple @@ -48,6 +48,8 @@ def __init__(self, player_logic: "WitnessPlayerLogic", world: "WitnessWorld", self.PRE_PICKED_HUNT_ENTITIES = pre_picked_entities.copy() self.HUNT_ENTITIES: Set[str] = set() + self._add_plandoed_hunt_panels_to_pre_picked() + self.ALL_ELIGIBLE_ENTITIES, self.ELIGIBLE_ENTITIES_PER_AREA = self._get_eligible_panels() def pick_panel_hunt_panels(self, total_amount: int) -> Set[str]: @@ -69,24 +71,51 @@ def pick_panel_hunt_panels(self, total_amount: int) -> Set[str]: return self.HUNT_ENTITIES - def _entity_is_eligible(self, panel_hex: str) -> bool: + def _entity_is_eligible(self, panel_hex: str, plando: bool = False) -> bool: """ Determine whether an entity is eligible for entity hunt based on player options. """ panel_obj = static_witness_logic.ENTITIES_BY_HEX[panel_hex] - return ( - self.player_logic.solvability_guaranteed(panel_hex) - and panel_hex not in self.player_logic.EXCLUDED_ENTITIES - and not ( - # Due to an edge case, Discards have to be on in disable_non_randomized even if Discard Shuffle is off. - # However, I don't think they should be hunt panels in this case. - self.player_options.disable_non_randomized_puzzles - and not self.player_options.shuffle_discarded_panels - and panel_obj["locationType"] == "Discard" - ) + if not self.player_logic.solvability_guaranteed(panel_hex) or panel_hex in self.player_logic.EXCLUDED_ENTITIES: + if plando: + warning(f"Panel {panel_obj['checkName']} is disabled / excluded and thus not eligible for panel hunt.") + return False + + return plando or not ( + # Due to an edge case, Discards have to be on in disable_non_randomized even if Discard Shuffle is off. + # However, I don't think they should be hunt panels in this case. + self.player_options.disable_non_randomized_puzzles + and not self.player_options.shuffle_discarded_panels + and panel_obj["locationType"] == "Discard" ) + def _add_plandoed_hunt_panels_to_pre_picked(self) -> None: + """ + Add panels the player explicitly specified to be included in panel hunt to the pre picked hunt panels. + Output a warning if a panel could not be added for some reason. + """ + + # Plandoed hunt panels should be in random order, but deterministic by seed, so we sort, then shuffle + panels_to_plando = sorted(self.player_options.panel_hunt_plando.value) + self.random.shuffle(panels_to_plando) + + for location_name in panels_to_plando: + entity_hex = static_witness_logic.ENTITIES_BY_NAME[location_name]["entity_hex"] + + if entity_hex in self.PRE_PICKED_HUNT_ENTITIES: + continue + + if self._entity_is_eligible(entity_hex, plando=True): + if len(self.PRE_PICKED_HUNT_ENTITIES) == self.player_options.panel_hunt_total: + warning( + f"Panel {location_name} could not be plandoed as a hunt panel for {self.player_name}'s world, " + f"because it would exceed their panel hunt total." + ) + continue + + self.PRE_PICKED_HUNT_ENTITIES.add(entity_hex) + def _get_eligible_panels(self) -> Tuple[List[str], Dict[str, Set[str]]]: """ There are some entities that are not allowed for panel hunt for various technical of gameplay reasons. @@ -215,6 +244,10 @@ def _replace_unfair_hunt_entities_with_good_hunt_entities(self) -> None: if good_entity in self.HUNT_ENTITIES or good_entity not in self.ALL_ELIGIBLE_ENTITIES: continue + # ... and it's not a forced pick that should stay the same ... + if bad_entitiy in self.PRE_PICKED_HUNT_ENTITIES: + continue + # ... replace the bad entity with the good entity. self.HUNT_ENTITIES.remove(bad_entitiy) self.HUNT_ENTITIES.add(good_entity) diff --git a/worlds/witness/options.py b/worlds/witness/options.py index b5c15e242f10..d739517870a5 100644 --- a/worlds/witness/options.py +++ b/worlds/witness/options.py @@ -5,6 +5,7 @@ from Options import ( Choice, DefaultOnToggle, + LocationSet, OptionDict, OptionError, OptionGroup, @@ -17,6 +18,7 @@ from .data import static_logic as static_witness_logic from .data.item_definition_classes import ItemCategory, WeightedItemDefinition +from .entity_hunt import ALL_HUNTABLE_PANELS class DisableNonRandomizedPuzzles(Toggle): @@ -268,6 +270,16 @@ class PanelHuntDiscourageSameAreaFactor(Range): default = 40 +class PanelHuntPlando(LocationSet): + """ + Specify specific hunt panels you want for your panel hunt game. + """ + + display_name = "Panel Hunt Plando" + + valid_keys = [static_witness_logic.ENTITIES_BY_HEX[panel_hex]["checkName"] for panel_hex in ALL_HUNTABLE_PANELS] + + class PuzzleRandomization(Choice): """ Puzzles in this randomizer are randomly generated. This option changes the difficulty/types of puzzles. @@ -477,6 +489,7 @@ class TheWitnessOptions(PerGameCommonOptions): panel_hunt_required_percentage: PanelHuntRequiredPercentage panel_hunt_postgame: PanelHuntPostgame panel_hunt_discourage_same_area_factor: PanelHuntDiscourageSameAreaFactor + panel_hunt_plando: PanelHuntPlando early_caves: EarlyCaves early_symbol_item: EarlySymbolItem elevators_come_to_you: ElevatorsComeToYou @@ -505,6 +518,7 @@ class TheWitnessOptions(PerGameCommonOptions): PanelHuntTotal, PanelHuntPostgame, PanelHuntDiscourageSameAreaFactor, + PanelHuntPlando, ], start_collapsed=True), OptionGroup("Locations", [ ShuffleDiscardedPanels, From 9815306875f8c6a4d683679a090babc0b4f96e0d Mon Sep 17 00:00:00 2001 From: qwint Date: Thu, 12 Dec 2024 14:30:49 -0500 Subject: [PATCH 024/144] Docs: Use ModuleUpdate.py #3785 --- docs/running from source.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/running from source.md b/docs/running from source.md index 33d6b3928e54..8e8b4f4b61c3 100644 --- a/docs/running from source.md +++ b/docs/running from source.md @@ -43,9 +43,9 @@ Recommended steps [Discord in #ap-core-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808) * It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/) - * Run Generate.py which will prompt installation of missing modules, press enter to confirm - * In PyCharm: right-click Generate.py and select `Run 'Generate'` - * Without PyCharm: open a command prompt in the source folder and type `py Generate.py` + * Run ModuleUpdate.py which will prompt installation of missing modules, press enter to confirm + * In PyCharm: right-click ModuleUpdate.py and select `Run 'ModuleUpdate'` + * Without PyCharm: open a command prompt in the source folder and type `py ModuleUpdate.py` ## macOS From 1ca8d3e4a8b4a4a3850a0829c7c934bc4f8475c6 Mon Sep 17 00:00:00 2001 From: qwint Date: Thu, 12 Dec 2024 15:24:38 -0500 Subject: [PATCH 025/144] Docs: add description of Indirect Condition problem (#4295) * Docs: Dev FAQ - About indirect conditions I wrote up a big effortpost about indirect conditions for nex on the [DS3 3.0 PR](https://github.com/ArchipelagoMW/Archipelago/pull/3128#discussion_r1693843193). The version I'm [PRing to the world API document](https://github.com/ArchipelagoMW/Archipelago/pull/3552) is very brief and unnuanced, because I'd rather people use too many indirect conditions than too few. But that might leave some devs wanting to know more. I think that comment on nex's DS3 PR is probably the best detailed explanation for indirect conditions that exists currently. So I think it's good if it exists somewhere. And the FAQ doc seems like the best place right now, because I don't want to write an entirely new doc at the moment. * Actually copy in the text * Update docs/apworld_dev_faq.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update docs/apworld_dev_faq.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update docs/apworld_dev_faq.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update docs/apworld_dev_faq.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update docs/apworld_dev_faq.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update docs/apworld_dev_faq.md Co-authored-by: Scipio Wright * Update docs/apworld_dev_faq.md Co-authored-by: Scipio Wright * Update docs/apworld_dev_faq.md Co-authored-by: qwint * Update docs/apworld_dev_faq.md Co-authored-by: qwint * Update apworld_dev_faq.md * Update docs/apworld_dev_faq.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update apworld_dev_faq.md * Update docs/apworld_dev_faq.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update docs/apworld_dev_faq.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update docs/apworld_dev_faq.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update apworld_dev_faq.md * Update docs/apworld_dev_faq.md Co-authored-by: qwint * Update docs/apworld_dev_faq.md Co-authored-by: qwint * fix the last couple of wording issues I have with the indirect condition section to apworld dev faq doc * I didn't like that wording * Apply suggestions from code review Co-authored-by: Scipio Wright * Apply suggestions from code review Co-authored-by: Scipio Wright * Update docs/apworld_dev_faq.md Co-authored-by: Scipio Wright * Update docs/apworld_dev_faq.md * Update docs/apworld_dev_faq.md Co-authored-by: Scipio Wright --------- Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Co-authored-by: Scipio Wright --- docs/apworld_dev_faq.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/apworld_dev_faq.md b/docs/apworld_dev_faq.md index 8d9429afa321..769a2fb3a0a7 100644 --- a/docs/apworld_dev_faq.md +++ b/docs/apworld_dev_faq.md @@ -43,3 +43,26 @@ A faster alternative to the `for` loop would be to use a [list comprehension](ht ```py item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))] ``` + +--- + +### I learned about indirect conditions in the world API document, but I want to know more. What are they and why are they necessary? + +The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and **when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is quite complicated. + +Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph. It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to the queue until there is nothing more to check. + +For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region access, then the following may happen: +1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been reached yet during the graph search. +2. Then, the region in its access_rule is determined to be reachable. + +This entrance *would* be in logic if it were rechecked, but it won't be rechecked this cycle. +To account for this case, AP would have to recheck all entrances every time a new region is reached until no new regions are reached. + +An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep if a specific region is reached during it. +This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness), using them is significantly faster than just "rechecking each entrance until nothing new is found". +The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they call `region.can_reach` on their respective parent/source region. + +We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition, and that some games have very complex access rules. +As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682) being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of checking each entrance whenever a region has been reached, although this does come with a performance cost. +Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance → region dependencies, making indirect conditions preferred because they are much faster. From 8d9454ea3bca864cc0e3f20f39563966feeffb86 Mon Sep 17 00:00:00 2001 From: qwint Date: Thu, 12 Dec 2024 15:36:56 -0500 Subject: [PATCH 026/144] Core: cast all the settings values so they don't try to get pickled later #4362 --- WebHostLib/generate.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index b19f3d483515..0bd9f7e5e066 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -31,11 +31,11 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[s server_options = { "hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)), - "release_mode": options_source.get("release_mode", ServerOptions.release_mode), - "remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode), - "collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode), + "release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)), + "remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)), + "collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)), "item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))), - "server_password": options_source.get("server_password", None), + "server_password": str(options_source.get("server_password", None)), } generator_options = { "spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)), From ccea6bcf51143d9b87543e9f03e17088ccf44667 Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Fri, 13 Dec 2024 16:49:30 -0500 Subject: [PATCH 027/144] LADX: Improve icon guesses for foreign items (#2201) * synonyms to new file, many added * handle singular rupee * remove redundant map and compass entries * automatic pluralization * add guardian acorn and piece of power * move phrases to ItemIconGuessing.py * organize, comment * fix tab spacing * fix * add tunic and noita synonyms * remove triangle instrument synonym * reorganize, add some matches * add tunic lucky up Co-authored-by: Scipio Wright * Update worlds/ladx/ItemIconGuessing.py Co-authored-by: Scipio Wright * handle camelCase and single rupee * add indicate_progression option Adds alternative system for foreign item icons that simply indicates whether or not the item is a progression item. * improve splitting drops some more characters, and also dont bother with rejoined stuff in name_cache because our splitting is better * the witness stuff * forbid more * remove boost and surge * Update worlds/ladx/ItemIconGuessing.py Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> * match by game name look at the name of the foreign game and only use game-specific entries for that game * show message for all key drops * updates from async test * vi suggestions * Adding FNAFW suggestions from @lolz1190 (#40) * Adding FNAFW suggestions from @lolz1190 * missing comma --------- Co-authored-by: threeandthreee --------- Co-authored-by: Scipio Wright Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Co-authored-by: palex00 <32203971+palex00@users.noreply.github.com> --- worlds/ladx/ItemIconGuessing.py | 531 ++++++++++++++++++ worlds/ladx/Items.py | 6 +- worlds/ladx/LADXR/locations/constants.py | 4 + worlds/ladx/LADXR/locations/items.py | 4 + .../ladx/LADXR/patches/bank3e.asm/chest.asm | 4 +- .../LADXR/patches/bank3e.asm/itemnames.asm | 8 +- worlds/ladx/LADXR/patches/droppedKey.py | 8 +- worlds/ladx/Options.py | 15 + worlds/ladx/__init__.py | 85 ++- 9 files changed, 603 insertions(+), 62 deletions(-) create mode 100644 worlds/ladx/ItemIconGuessing.py diff --git a/worlds/ladx/ItemIconGuessing.py b/worlds/ladx/ItemIconGuessing.py new file mode 100644 index 000000000000..e3d2ad7b8295 --- /dev/null +++ b/worlds/ladx/ItemIconGuessing.py @@ -0,0 +1,531 @@ +BLOCKED_ASSOCIATIONS = [ + # MAX_ARROWS_UPGRADE, MAX_BOMBS_UPGRADE, MAX_POWDER_UPGRADE + # arrows and bombs will be matched to arrow and bomb respectively through pluralization + "ARROWS", + "BOMBS", + "MAX", + "UPGRADE", + + "TAIL", # TAIL_KEY + "ANGLER", # ANGLER_KEY + "FACE", # FACE_KEY + "BIRD", # BIRD_KEY + "SLIME", # SLIME_KEY + "NIGHTMARE",# NIGHTMARE_KEY + + "BLUE", # BLUE_TUNIC + "RED", # RED_TUNIC + + "TRADING", # TRADING_ITEM_* + "ITEM", # TRADING_ITEM_* + + "BAD", # BAD_HEART_CONTAINER + "GOLD", # GOLD_LEAF + "MAGIC", # MAGIC_POWDER, MAGIC_ROD + "MESSAGE", # MESSAGE (Master Stalfos' Message) + "PEGASUS", # PEGASUS_BOOTS + "PIECE", # HEART_PIECE, PIECE_OF_POWER + "POWER", # POWER_BRACELET, PIECE_OF_POWER + "SINGLE", # SINGLE_ARROW + "STONE", # STONE_BEAK + + "BEAK1", + "BEAK2", + "BEAK3", + "BEAK4", + "BEAK5", + "BEAK6", + "BEAK7", + "BEAK8", + + "COMPASS1", + "COMPASS2", + "COMPASS3", + "COMPASS4", + "COMPASS5", + "COMPASS6", + "COMPASS7", + "COMPASS8", + + "MAP1", + "MAP2", + "MAP3", + "MAP4", + "MAP5", + "MAP6", + "MAP7", + "MAP8", +] + +# Single word synonyms for Link's Awakening items, for generic matching. +SYNONYMS = { + # POWER_BRACELET + 'ANKLET': 'POWER_BRACELET', + 'ARMLET': 'POWER_BRACELET', + 'BAND': 'POWER_BRACELET', + 'BANGLE': 'POWER_BRACELET', + 'BRACER': 'POWER_BRACELET', + 'CARRY': 'POWER_BRACELET', + 'CIRCLET': 'POWER_BRACELET', + 'CROISSANT': 'POWER_BRACELET', + 'GAUNTLET': 'POWER_BRACELET', + 'GLOVE': 'POWER_BRACELET', + 'RING': 'POWER_BRACELET', + 'STRENGTH': 'POWER_BRACELET', + + # SHIELD + 'AEGIS': 'SHIELD', + 'BUCKLER': 'SHIELD', + 'SHLD': 'SHIELD', + + # BOW + 'BALLISTA': 'BOW', + + # HOOKSHOT + 'GRAPPLE': 'HOOKSHOT', + 'GRAPPLING': 'HOOKSHOT', + 'ROPE': 'HOOKSHOT', + + # MAGIC_ROD + 'BEAM': 'MAGIC_ROD', + 'CANE': 'MAGIC_ROD', + 'STAFF': 'MAGIC_ROD', + 'WAND': 'MAGIC_ROD', + + # PEGASUS_BOOTS + 'BOOT': 'PEGASUS_BOOTS', + 'GREAVES': 'PEGASUS_BOOTS', + 'RUN': 'PEGASUS_BOOTS', + 'SHOE': 'PEGASUS_BOOTS', + 'SPEED': 'PEGASUS_BOOTS', + + # OCARINA + 'FLUTE': 'OCARINA', + 'RECORDER': 'OCARINA', + + # FEATHER + 'JUMP': 'FEATHER', + 'PLUME': 'FEATHER', + 'WING': 'FEATHER', + + # SHOVEL + 'DIG': 'SHOVEL', + + # MAGIC_POWDER + 'BAG': 'MAGIC_POWDER', + 'CASE': 'MAGIC_POWDER', + 'DUST': 'MAGIC_POWDER', + 'POUCH': 'MAGIC_POWDER', + 'SACK': 'MAGIC_POWDER', + + # BOMB + 'BLAST': 'BOMB', + 'BOMBCHU': 'BOMB', + 'FIRECRACKER': 'BOMB', + 'TNT': 'BOMB', + + # SWORD + 'BLADE': 'SWORD', + 'CUT': 'SWORD', + 'DAGGER': 'SWORD', + 'DIRK': 'SWORD', + 'EDGE': 'SWORD', + 'EPEE': 'SWORD', + 'EXCALIBUR': 'SWORD', + 'FALCHION': 'SWORD', + 'KATANA': 'SWORD', + 'KNIFE': 'SWORD', + 'MACHETE': 'SWORD', + 'MASAMUNE': 'SWORD', + 'MURASAME': 'SWORD', + 'SABER': 'SWORD', + 'SABRE': 'SWORD', + 'SCIMITAR': 'SWORD', + 'SLASH': 'SWORD', + + # FLIPPERS + 'FLIPPER': 'FLIPPERS', + 'SWIM': 'FLIPPERS', + + # MEDICINE + 'BOTTLE': 'MEDICINE', + 'FLASK': 'MEDICINE', + 'LEMONADE': 'MEDICINE', + 'POTION': 'MEDICINE', + 'TEA': 'MEDICINE', + + # TAIL_KEY + + # ANGLER_KEY + + # FACE_KEY + + # BIRD_KEY + + # SLIME_KEY + + # GOLD_LEAF + 'HERB': 'GOLD_LEAF', + + # RUPEES_20 + 'COIN': 'RUPEES_20', + 'MONEY': 'RUPEES_20', + 'RUPEE': 'RUPEES_20', + + # RUPEES_50 + + # RUPEES_100 + + # RUPEES_200 + + # RUPEES_500 + 'GEM': 'RUPEES_500', + 'JEWEL': 'RUPEES_500', + + # SEASHELL + 'CARAPACE': 'SEASHELL', + 'CONCH': 'SEASHELL', + 'SHELL': 'SEASHELL', + + # MESSAGE (master stalfos message) + 'NOTHING': 'MESSAGE', + 'TRAP': 'MESSAGE', + + # BOOMERANG + 'BOOMER': 'BOOMERANG', + + # HEART_PIECE + + # BOWWOW + 'BEAST': 'BOWWOW', + 'PET': 'BOWWOW', + + # ARROWS_10 + + # SINGLE_ARROW + 'MISSILE': 'SINGLE_ARROW', + 'QUIVER': 'SINGLE_ARROW', + + # ROOSTER + 'BIRD': 'ROOSTER', + 'CHICKEN': 'ROOSTER', + 'CUCCO': 'ROOSTER', + 'FLY': 'ROOSTER', + 'GRIFFIN': 'ROOSTER', + 'GRYPHON': 'ROOSTER', + + # MAX_POWDER_UPGRADE + + # MAX_BOMBS_UPGRADE + + # MAX_ARROWS_UPGRADE + + # RED_TUNIC + + # BLUE_TUNIC + 'ARMOR': 'BLUE_TUNIC', + 'MAIL': 'BLUE_TUNIC', + 'SUIT': 'BLUE_TUNIC', + + # HEART_CONTAINER + 'TANK': 'HEART_CONTAINER', + + # TOADSTOOL + 'FUNGAL': 'TOADSTOOL', + 'FUNGUS': 'TOADSTOOL', + 'MUSHROOM': 'TOADSTOOL', + 'SHROOM': 'TOADSTOOL', + + # GUARDIAN_ACORN + 'NUT': 'GUARDIAN_ACORN', + 'SEED': 'GUARDIAN_ACORN', + + # KEY + 'DOOR': 'KEY', + 'GATE': 'KEY', + 'KEY': 'KEY', # Without this, foreign keys show up as nightmare keys + 'LOCK': 'KEY', + 'PANEL': 'KEY', + 'UNLOCK': 'KEY', + + # NIGHTMARE_KEY + + # MAP + + # COMPASS + + # STONE_BEAK + 'FOSSIL': 'STONE_BEAK', + 'RELIC': 'STONE_BEAK', + + # SONG1 + 'BOLERO': 'SONG1', + 'LULLABY': 'SONG1', + 'MELODY': 'SONG1', + 'MINUET': 'SONG1', + 'NOCTURNE': 'SONG1', + 'PRELUDE': 'SONG1', + 'REQUIEM': 'SONG1', + 'SERENADE': 'SONG1', + 'SONG': 'SONG1', + + # SONG2 + 'FISH': 'SONG2', + 'SURF': 'SONG2', + + # SONG3 + 'FROG': 'SONG3', + + # INSTRUMENT1 + 'CELLO': 'INSTRUMENT1', + 'GUITAR': 'INSTRUMENT1', + 'LUTE': 'INSTRUMENT1', + 'VIOLIN': 'INSTRUMENT1', + + # INSTRUMENT2 + 'HORN': 'INSTRUMENT2', + + # INSTRUMENT3 + 'BELL': 'INSTRUMENT3', + 'CHIME': 'INSTRUMENT3', + + # INSTRUMENT4 + 'HARP': 'INSTRUMENT4', + 'KANTELE': 'INSTRUMENT4', + + # INSTRUMENT5 + 'MARIMBA': 'INSTRUMENT5', + 'XYLOPHONE': 'INSTRUMENT5', + + # INSTRUMENT6 (triangle) + + # INSTRUMENT7 + 'KEYBOARD': 'INSTRUMENT7', + 'ORGAN': 'INSTRUMENT7', + 'PIANO': 'INSTRUMENT7', + + # INSTRUMENT8 + 'DRUM': 'INSTRUMENT8', + + # TRADING_ITEM_YOSHI_DOLL + 'DINOSAUR': 'TRADING_ITEM_YOSHI_DOLL', + 'DRAGON': 'TRADING_ITEM_YOSHI_DOLL', + 'TOY': 'TRADING_ITEM_YOSHI_DOLL', + + # TRADING_ITEM_RIBBON + 'HAIRBAND': 'TRADING_ITEM_RIBBON', + 'HAIRPIN': 'TRADING_ITEM_RIBBON', + + # TRADING_ITEM_DOG_FOOD + 'CAN': 'TRADING_ITEM_DOG_FOOD', + + # TRADING_ITEM_BANANAS + 'BANANA': 'TRADING_ITEM_BANANAS', + + # TRADING_ITEM_STICK + 'BRANCH': 'TRADING_ITEM_STICK', + 'TWIG': 'TRADING_ITEM_STICK', + + # TRADING_ITEM_HONEYCOMB + 'BEEHIVE': 'TRADING_ITEM_HONEYCOMB', + 'HIVE': 'TRADING_ITEM_HONEYCOMB', + 'HONEY': 'TRADING_ITEM_HONEYCOMB', + + # TRADING_ITEM_PINEAPPLE + 'FOOD': 'TRADING_ITEM_PINEAPPLE', + 'FRUIT': 'TRADING_ITEM_PINEAPPLE', + 'GOURD': 'TRADING_ITEM_PINEAPPLE', + + # TRADING_ITEM_HIBISCUS + 'FLOWER': 'TRADING_ITEM_HIBISCUS', + 'PETAL': 'TRADING_ITEM_HIBISCUS', + + # TRADING_ITEM_LETTER + 'CARD': 'TRADING_ITEM_LETTER', + 'MESSAGE': 'TRADING_ITEM_LETTER', + + # TRADING_ITEM_BROOM + 'SWEEP': 'TRADING_ITEM_BROOM', + + # TRADING_ITEM_FISHING_HOOK + 'CLAW': 'TRADING_ITEM_FISHING_HOOK', + + # TRADING_ITEM_NECKLACE + 'AMULET': 'TRADING_ITEM_NECKLACE', + 'BEADS': 'TRADING_ITEM_NECKLACE', + 'PEARLS': 'TRADING_ITEM_NECKLACE', + 'PENDANT': 'TRADING_ITEM_NECKLACE', + 'ROSARY': 'TRADING_ITEM_NECKLACE', + + # TRADING_ITEM_SCALE + + # TRADING_ITEM_MAGNIFYING_GLASS + 'FINDER': 'TRADING_ITEM_MAGNIFYING_GLASS', + 'LENS': 'TRADING_ITEM_MAGNIFYING_GLASS', + 'MIRROR': 'TRADING_ITEM_MAGNIFYING_GLASS', + 'SCOPE': 'TRADING_ITEM_MAGNIFYING_GLASS', + 'XRAY': 'TRADING_ITEM_MAGNIFYING_GLASS', + + # PIECE_OF_POWER + 'TRIANGLE': 'PIECE_OF_POWER', + 'POWER': 'PIECE_OF_POWER', + 'TRIFORCE': 'PIECE_OF_POWER', +} + +# For generic multi-word matches. +PHRASES = { + 'BIG KEY': 'NIGHTMARE_KEY', + 'BOSS KEY': 'NIGHTMARE_KEY', + 'HEART PIECE': 'HEART_PIECE', + 'PIECE OF HEART': 'HEART_PIECE', +} + +# All following will only be used to match items for the specific game. +# Item names will be uppercased when comparing. +# Can be multi-word. +GAME_SPECIFIC_PHRASES = { + 'Final Fantasy': { + 'OXYALE': 'MEDICINE', + 'VORPAL': 'SWORD', + 'XCALBER': 'SWORD', + }, + + 'The Legend of Zelda': { + 'WATER OF LIFE': 'MEDICINE', + }, + + 'The Legend of Zelda - Oracle of Seasons': { + 'RARE PEACH STONE': 'HEART_PIECE', + }, + + 'Noita': { + 'ALL-SEEING EYE': 'TRADING_ITEM_MAGNIFYING_GLASS', # lets you find secrets + }, + + 'Ocarina of Time': { + 'COJIRO': 'ROOSTER', + }, + + 'SMZ3': { + 'BIGKEY': 'NIGHTMARE_KEY', + 'BYRNA': 'MAGIC_ROD', + 'HEARTPIECE': 'HEART_PIECE', + 'POWERBOMB': 'BOMB', + 'SOMARIA': 'MAGIC_ROD', + 'SUPER': 'SINGLE_ARROW', + }, + + 'Sonic Adventure 2 Battle': { + 'CHAOS EMERALD': 'PIECE_OF_POWER', + }, + + 'Super Mario 64': { + 'POWER STAR': 'PIECE_OF_POWER', + }, + + 'Super Mario World': { + 'P-BALLOON': 'FEATHER', + }, + + 'Super Metroid': { + 'POWER BOMB': 'BOMB', + }, + + 'The Witness': { + 'BONK': 'BOMB', + 'BUNKER LASER': 'INSTRUMENT4', + 'DESERT LASER': 'INSTRUMENT5', + 'JUNGLE LASER': 'INSTRUMENT4', + 'KEEP LASER': 'INSTRUMENT7', + 'MONASTERY LASER': 'INSTRUMENT1', + 'POWER SURGE': 'BOMB', + 'PUZZLE SKIP': 'GOLD_LEAF', + 'QUARRY LASER': 'INSTRUMENT8', + 'SHADOWS LASER': 'INSTRUMENT1', + 'SHORTCUTS': 'KEY', + 'SLOWNESS': 'BOMB', + 'SWAMP LASER': 'INSTRUMENT2', + 'SYMMETRY LASER': 'INSTRUMENT6', + 'TOWN LASER': 'INSTRUMENT3', + 'TREEHOUSE LASER': 'INSTRUMENT2', + 'WATER PUMPS': 'KEY', + }, + + 'TUNIC': { + "AURA'S GEM": 'SHIELD', # card that enhances the shield + 'DUSTY': 'TRADING_ITEM_BROOM', # a broom + 'HERO RELIC - HP': 'TRADING_ITEM_HIBISCUS', + 'HERO RELIC - MP': 'TOADSTOOL', + 'HERO RELIC - SP': 'FEATHER', + 'HP BERRY': 'GUARDIAN_ACORN', + 'HP OFFERING': 'TRADING_ITEM_HIBISCUS', # a flower + 'LUCKY CUP': 'HEART_CONTAINER', # card with a heart on it + 'INVERTED ASH': 'MEDICINE', # card with a potion on it + 'MAGIC ORB': 'HOOKSHOT', + 'MP BERRY': 'GUARDIAN_ACORN', + 'MP OFFERING': 'TOADSTOOL', # a mushroom + 'QUESTAGON': 'PIECE_OF_POWER', # triforce piece equivalent + 'SP OFFERING': 'FEATHER', # a feather + 'SPRING FALLS': 'TRADING_ITEM_HIBISCUS', # a flower + }, + + 'FNaFW': { + 'Freddy': 'TRADING_ITEM_YOSHI_DOLL', # all of these are animatronics, aka dolls. + 'Bonnie': 'TRADING_ITEM_YOSHI_DOLL', + 'Chica': 'TRADING_ITEM_YOSHI_DOLL', + 'Foxy': 'TRADING_ITEM_YOSHI_DOLL', + 'Toy Bonnie': 'TRADING_ITEM_YOSHI_DOLL', + 'Toy Chica': 'TRADING_ITEM_YOSHI_DOLL', + 'Toy Freddy': 'TRADING_ITEM_YOSHI_DOLL', + 'Mangle': 'TRADING_ITEM_YOSHI_DOLL', + 'Balloon Boy': 'TRADING_ITEM_YOSHI_DOLL', + 'JJ': 'TRADING_ITEM_YOSHI_DOLL', + 'Phantom Freddy': 'TRADING_ITEM_YOSHI_DOLL', + 'Phantom BB': 'TRADING_ITEM_YOSHI_DOLL', + 'Phantom Chica': 'TRADING_ITEM_YOSHI_DOLL', + 'Phantom Mangle': 'TRADING_ITEM_YOSHI_DOLL', + 'Withered Foxy': 'TRADING_ITEM_YOSHI_DOLL', + 'Phantom Foxy': 'TRADING_ITEM_YOSHI_DOLL', + 'Withered Chica': 'TRADING_ITEM_YOSHI_DOLL', + 'Withered Freddy': 'TRADING_ITEM_YOSHI_DOLL', + 'Withered Bonnie': 'TRADING_ITEM_YOSHI_DOLL', + 'Shadow Freddy': 'TRADING_ITEM_YOSHI_DOLL', + 'Marionette': 'TRADING_ITEM_YOSHI_DOLL', + 'Phantom Marionette': 'TRADING_ITEM_YOSHI_DOLL', + 'Golden Freddy': 'TRADING_ITEM_YOSHI_DOLL', + 'Paperpals': 'TRADING_ITEM_YOSHI_DOLL', + 'Nightmare Freddy': 'TRADING_ITEM_YOSHI_DOLL', + 'Nightmare Bonnie': 'TRADING_ITEM_YOSHI_DOLL', + 'Nightmare Chica': 'TRADING_ITEM_YOSHI_DOLL', + 'Nightmare Foxy': 'TRADING_ITEM_YOSHI_DOLL', + 'Endo 01': 'TRADING_ITEM_YOSHI_DOLL', + 'Endo 02': 'TRADING_ITEM_YOSHI_DOLL', + 'Plushtrap': 'TRADING_ITEM_YOSHI_DOLL', + 'Endoplush': 'TRADING_ITEM_YOSHI_DOLL', + 'Springtrap': 'TRADING_ITEM_YOSHI_DOLL', + 'RWQFSFASXC': 'TRADING_ITEM_YOSHI_DOLL', + 'Crying Child': 'TRADING_ITEM_YOSHI_DOLL', + 'Funtime Foxy': 'TRADING_ITEM_YOSHI_DOLL', + 'Nightmare Fredbear': 'TRADING_ITEM_YOSHI_DOLL', + 'Nightmare': 'TRADING_ITEM_YOSHI_DOLL', + 'Fredbear': 'TRADING_ITEM_YOSHI_DOLL', + 'Spring Bonnie': 'TRADING_ITEM_YOSHI_DOLL', + 'Jack-O-Chica': 'TRADING_ITEM_YOSHI_DOLL', + 'Nightmare BB': 'TRADING_ITEM_YOSHI_DOLL', + 'Coffee': 'TRADING_ITEM_YOSHI_DOLL', + 'Jack-O-Bonnie': 'TRADING_ITEM_YOSHI_DOLL', + 'Purpleguy': 'TRADING_ITEM_YOSHI_DOLL', + 'Nightmarionne': 'TRADING_ITEM_YOSHI_DOLL', + 'Mr. Chipper': 'TRADING_ITEM_YOSHI_DOLL', + 'Animdude': 'TRADING_ITEM_YOSHI_DOLL', + 'Progressive Endoskeleton': 'BLUE_TUNIC', # basically armor you wear to give you more defense + '25 Tokens': 'RUPEES_20', # money + '50 Tokens': 'RUPEES_50', + '100 Tokens': 'RUPEES_100', + '250 Tokens': 'RUPEES_200', + '500 Tokens': 'RUPEES_500', + '1000 Tokens': 'RUPEES_500', + '2500 Tokens': 'RUPEES_500', + '5000 Tokens': 'RUPEES_500', + }, +} diff --git a/worlds/ladx/Items.py b/worlds/ladx/Items.py index 2a64c59394e6..32d466373cae 100644 --- a/worlds/ladx/Items.py +++ b/worlds/ladx/Items.py @@ -98,6 +98,7 @@ class ItemName: HEART_CONTAINER = "Heart Container" BAD_HEART_CONTAINER = "Bad Heart Container" TOADSTOOL = "Toadstool" + GUARDIAN_ACORN = "Guardian Acorn" KEY = "Key" KEY1 = "Small Key (Tail Cave)" KEY2 = "Small Key (Bottle Grotto)" @@ -173,6 +174,7 @@ class ItemName: TRADING_ITEM_NECKLACE = "Necklace" TRADING_ITEM_SCALE = "Scale" TRADING_ITEM_MAGNIFYING_GLASS = "Magnifying Glass" + PIECE_OF_POWER = "Piece Of Power" trade_item_prog = ItemClassification.progression @@ -219,6 +221,7 @@ class ItemName: ItemData(ItemName.HEART_CONTAINER, "HEART_CONTAINER", ItemClassification.useful), #ItemData(ItemName.BAD_HEART_CONTAINER, "BAD_HEART_CONTAINER", ItemClassification.trap), ItemData(ItemName.TOADSTOOL, "TOADSTOOL", ItemClassification.progression), + ItemData(ItemName.GUARDIAN_ACORN, "GUARDIAN_ACORN", ItemClassification.filler), DungeonItemData(ItemName.KEY, "KEY", ItemClassification.progression), DungeonItemData(ItemName.KEY1, "KEY1", ItemClassification.progression), DungeonItemData(ItemName.KEY2, "KEY2", ItemClassification.progression), @@ -293,7 +296,8 @@ class ItemName: TradeItemData(ItemName.TRADING_ITEM_FISHING_HOOK, "TRADING_ITEM_FISHING_HOOK", trade_item_prog, "Grandma (Animal Village)"), TradeItemData(ItemName.TRADING_ITEM_NECKLACE, "TRADING_ITEM_NECKLACE", trade_item_prog, "Fisher (Martha's Bay)"), TradeItemData(ItemName.TRADING_ITEM_SCALE, "TRADING_ITEM_SCALE", trade_item_prog, "Mermaid (Martha's Bay)"), - TradeItemData(ItemName.TRADING_ITEM_MAGNIFYING_GLASS, "TRADING_ITEM_MAGNIFYING_GLASS", trade_item_prog, "Mermaid Statue (Martha's Bay)") + TradeItemData(ItemName.TRADING_ITEM_MAGNIFYING_GLASS, "TRADING_ITEM_MAGNIFYING_GLASS", trade_item_prog, "Mermaid Statue (Martha's Bay)"), + ItemData(ItemName.PIECE_OF_POWER, "PIECE_OF_POWER", ItemClassification.filler), ] ladxr_item_to_la_item_name = { diff --git a/worlds/ladx/LADXR/locations/constants.py b/worlds/ladx/LADXR/locations/constants.py index a0489febc316..bcf22711bb7b 100644 --- a/worlds/ladx/LADXR/locations/constants.py +++ b/worlds/ladx/LADXR/locations/constants.py @@ -87,6 +87,8 @@ TOADSTOOL: 0x50, + GUARDIAN_ACORN: 0x51, + HEART_PIECE: 0x80, BOWWOW: 0x81, ARROWS_10: 0x82, @@ -128,4 +130,6 @@ TRADING_ITEM_NECKLACE: 0xA2, TRADING_ITEM_SCALE: 0xA3, TRADING_ITEM_MAGNIFYING_GLASS: 0xA4, + + PIECE_OF_POWER: 0xA5, } diff --git a/worlds/ladx/LADXR/locations/items.py b/worlds/ladx/LADXR/locations/items.py index 1ecc331f8580..56cc52232355 100644 --- a/worlds/ladx/LADXR/locations/items.py +++ b/worlds/ladx/LADXR/locations/items.py @@ -44,6 +44,8 @@ TOADSTOOL = "TOADSTOOL" +GUARDIAN_ACORN = "GUARDIAN_ACORN" + KEY = "KEY" KEY1 = "KEY1" KEY2 = "KEY2" @@ -124,3 +126,5 @@ TRADING_ITEM_NECKLACE = "TRADING_ITEM_NECKLACE" TRADING_ITEM_SCALE = "TRADING_ITEM_SCALE" TRADING_ITEM_MAGNIFYING_GLASS = "TRADING_ITEM_MAGNIFYING_GLASS" + +PIECE_OF_POWER = "PIECE_OF_POWER" \ No newline at end of file diff --git a/worlds/ladx/LADXR/patches/bank3e.asm/chest.asm b/worlds/ladx/LADXR/patches/bank3e.asm/chest.asm index 57771c17b3ca..de237c86293b 100644 --- a/worlds/ladx/LADXR/patches/bank3e.asm/chest.asm +++ b/worlds/ladx/LADXR/patches/bank3e.asm/chest.asm @@ -835,6 +835,7 @@ ItemSpriteTable: db $46, $1C ; NIGHTMARE_KEY8 db $46, $1C ; NIGHTMARE_KEY9 db $4C, $1C ; Toadstool + db $AE, $14 ; Guardian Acorn LargeItemSpriteTable: db $AC, $02, $AC, $22 ; heart piece @@ -874,6 +875,7 @@ LargeItemSpriteTable: db $D8, $0D, $DA, $0D ; TradeItem12 db $DC, $0D, $DE, $0D ; TradeItem13 db $E0, $0D, $E2, $0D ; TradeItem14 + db $14, $42, $14, $62 ; Piece Of Power ItemMessageTable: db $90, $3D, $89, $93, $94, $95, $96, $97, $98, $99, $9A, $9B, $9C, $9D, $D9, $A2 @@ -888,7 +890,7 @@ ItemMessageTable: ; $80 db $4F, $C8, $CA, $CB, $E2, $E3, $E4, $CC, $CD, $2A, $2B, $C9, $C9, $C9, $C9, $C9 db $C9, $C9, $C9, $C9, $C9, $C9, $B8, $44, $C9, $C9, $C9, $C9, $C9, $C9, $C9, $C9 - db $C9, $C9, $C9, $C9, $9D + db $C9, $C9, $C9, $C9, $9D, $C9 RenderDroppedKey: ;TODO: See EntityInitKeyDropPoint for a few special cases to unload. diff --git a/worlds/ladx/LADXR/patches/bank3e.asm/itemnames.asm b/worlds/ladx/LADXR/patches/bank3e.asm/itemnames.asm index 0c1bc9d699e4..c57ce2f81ccd 100644 --- a/worlds/ladx/LADXR/patches/bank3e.asm/itemnames.asm +++ b/worlds/ladx/LADXR/patches/bank3e.asm/itemnames.asm @@ -170,7 +170,7 @@ ItemNamePointers: dw ItemNameNightmareKey8 dw ItemNameNightmareKey9 dw ItemNameToadstool - dw ItemNameNone ; 0x51 + dw ItemNameGuardianAcorn dw ItemNameNone ; 0x52 dw ItemNameNone ; 0x53 dw ItemNameNone ; 0x54 @@ -254,6 +254,7 @@ ItemNamePointers: dw ItemTradeQuest12 dw ItemTradeQuest13 dw ItemTradeQuest14 + dw ItemPieceOfPower ItemNameNone: db m"NONE", $ff @@ -418,6 +419,8 @@ ItemNameNightmareKey9: db m"Got the {NIGHTMARE_KEY9}", $ff ItemNameToadstool: db m"Got the {TOADSTOOL}", $ff +ItemNameGuardianAcorn: + db m"Got a Guardian Acorn", $ff ItemNameHeartPiece: db m"Got the {HEART_PIECE}", $ff @@ -496,5 +499,8 @@ ItemTradeQuest13: db m"You've got the Scale", $ff ItemTradeQuest14: db m"You've got the Magnifying Lens", $ff + +ItemPieceOfPower: + db m"You've got a Piece of Power", $ff MultiNamePointers: \ No newline at end of file diff --git a/worlds/ladx/LADXR/patches/droppedKey.py b/worlds/ladx/LADXR/patches/droppedKey.py index d24b8b76c7a9..7853712a114a 100644 --- a/worlds/ladx/LADXR/patches/droppedKey.py +++ b/worlds/ladx/LADXR/patches/droppedKey.py @@ -24,14 +24,10 @@ def fixDroppedKey(rom): ld a, $06 ; giveItemMultiworld rst 8 - ldh a, [$F1] ; Load active sprite variant to see if this is just a normal small key - cp $1A - jr z, isAKey - - ;Show message (if not a key) + ;Show message ld a, $0A ; showMessageMultiworld rst 8 -isAKey: + ret """)) rom.patch(0x03, 0x24B7, "3E", "3E") # sanity check diff --git a/worlds/ladx/Options.py b/worlds/ladx/Options.py index 9414a7e3c89b..17052659157f 100644 --- a/worlds/ladx/Options.py +++ b/worlds/ladx/Options.py @@ -505,6 +505,19 @@ class InGameHints(DefaultOnToggle): display_name = "In-game Hints" + +class ForeignItemIcons(Choice): + """ + Choose how to display foreign items. + [Guess By Name] Foreign items can look like any Link's Awakening item. + [Indicate Progression] Foreign items are either a Piece of Power (progression) or Guardian Acorn (non-progression). + """ + display_name = "Foreign Item Icons" + option_guess_by_name = 0 + option_indicate_progression = 1 + default = option_guess_by_name + + ladx_option_groups = [ OptionGroup("Goal Options", [ Goal, @@ -537,6 +550,7 @@ class InGameHints(DefaultOnToggle): LinkPalette, Palette, TextShuffle, + ForeignItemIcons, APTitleScreen, GfxMod, Music, @@ -571,6 +585,7 @@ class LinksAwakeningOptions(PerGameCommonOptions): gfxmod: GfxMod palette: Palette text_shuffle: TextShuffle + foreign_item_icons: ForeignItemIcons shuffle_nightmare_keys: ShuffleNightmareKeys shuffle_small_keys: ShuffleSmallKeys shuffle_maps: ShuffleMaps diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index 7499aca8c404..8496d4cf49e3 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -4,6 +4,7 @@ import pkgutil import tempfile import typing +import re import bsdiff4 @@ -12,6 +13,7 @@ from Fill import fill_restrictive from worlds.AutoWorld import WebWorld, World from .Common import * +from . import ItemIconGuessing from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData, ladxr_item_to_la_item_name, links_awakening_items, links_awakening_items_by_name, links_awakening_item_name_groups) @@ -380,66 +382,36 @@ def priority(item): name_cache = {} # Tries to associate an icon from another game with an icon we have - def guess_icon_for_other_world(self, other): + def guess_icon_for_other_world(self, foreign_item): if not self.name_cache: - forbidden = [ - "TRADING", - "ITEM", - "BAD", - "SINGLE", - "UPGRADE", - "BLUE", - "RED", - "NOTHING", - "MESSAGE", - ] for item in ladxr_item_to_la_item_name.keys(): self.name_cache[item] = item splits = item.split("_") - self.name_cache["".join(splits)] = item - if 'RUPEES' in splits: - self.name_cache["".join(reversed(splits))] = item - for word in item.split("_"): - if word not in forbidden and not word.isnumeric(): + if word not in ItemIconGuessing.BLOCKED_ASSOCIATIONS and not word.isnumeric(): self.name_cache[word] = item - others = { - 'KEY': 'KEY', - 'COMPASS': 'COMPASS', - 'BIGKEY': 'NIGHTMARE_KEY', - 'MAP': 'MAP', - 'FLUTE': 'OCARINA', - 'SONG': 'OCARINA', - 'MUSHROOM': 'TOADSTOOL', - 'GLOVE': 'POWER_BRACELET', - 'BOOT': 'PEGASUS_BOOTS', - 'SHOE': 'PEGASUS_BOOTS', - 'SHOES': 'PEGASUS_BOOTS', - 'SANCTUARYHEARTCONTAINER': 'HEART_CONTAINER', - 'BOSSHEARTCONTAINER': 'HEART_CONTAINER', - 'HEARTCONTAINER': 'HEART_CONTAINER', - 'ENERGYTANK': 'HEART_CONTAINER', - 'MISSILE': 'SINGLE_ARROW', - 'BOMBS': 'BOMB', - 'BLUEBOOMERANG': 'BOOMERANG', - 'MAGICMIRROR': 'TRADING_ITEM_MAGNIFYING_GLASS', - 'MIRROR': 'TRADING_ITEM_MAGNIFYING_GLASS', - 'MESSAGE': 'TRADING_ITEM_LETTER', - # TODO: Also use AP item name - } - for name in others.values(): + for name in ItemIconGuessing.SYNONYMS.values(): assert name in self.name_cache, name assert name in CHEST_ITEMS, name - self.name_cache.update(others) - - - uppered = other.upper() - if "BIG KEY" in uppered: - return 'NIGHTMARE_KEY' - possibles = other.upper().split(" ") - rejoined = "".join(possibles) - if rejoined in self.name_cache: - return self.name_cache[rejoined] + self.name_cache.update(ItemIconGuessing.SYNONYMS) + pluralizations = {k + "S": v for k, v in self.name_cache.items()} + self.name_cache = pluralizations | self.name_cache + + uppered = foreign_item.name.upper() + foreign_game = self.multiworld.game[foreign_item.player] + phrases = ItemIconGuessing.PHRASES.copy() + if foreign_game in ItemIconGuessing.GAME_SPECIFIC_PHRASES: + phrases.update(ItemIconGuessing.GAME_SPECIFIC_PHRASES[foreign_game]) + + for phrase, icon in phrases.items(): + if phrase in uppered: + return icon + # pattern for breaking down camelCase, also separates out digits + pattern = re.compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-zA-Z])(?=\d)") + possibles = pattern.sub(' ', foreign_item.name).upper() + for ch in "[]()_": + possibles = possibles.replace(ch, " ") + possibles = possibles.split() for name in possibles: if name in self.name_cache: return self.name_cache[name] @@ -465,8 +437,15 @@ def generate_output(self, output_directory: str): # If the item name contains "sword", use a sword icon, etc # Otherwise, use a cute letter as the icon + elif self.options.foreign_item_icons == 'guess_by_name': + loc.ladxr_item.item = self.guess_icon_for_other_world(loc.item) + loc.ladxr_item.custom_item_name = loc.item.name + else: - loc.ladxr_item.item = self.guess_icon_for_other_world(loc.item.name) + if loc.item.advancement: + loc.ladxr_item.item = 'PIECE_OF_POWER' + else: + loc.ladxr_item.item = 'GUARDIAN_ACORN' loc.ladxr_item.custom_item_name = loc.item.name if loc.item: From 0370e669e57e767f8af23cc08b05468da8a72895 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Sun, 15 Dec 2024 21:28:51 +0000 Subject: [PATCH 028/144] Pokemon Emerald: Add Mr Briney's House indirect conditions (#4154) The `REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE109/BEACH` and `REGION_ROUTE109/BEACH -> REGION_DEWFORD_TOWN/MAIN` entrances require access to the `REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN` entrance in their access rules, so require indirect conditions for the parent_region of the entrance: `REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN`. --- worlds/pokemon_emerald/rules.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/worlds/pokemon_emerald/rules.py b/worlds/pokemon_emerald/rules.py index b8d1efb1a98d..828eb20f7218 100644 --- a/worlds/pokemon_emerald/rules.py +++ b/worlds/pokemon_emerald/rules.py @@ -416,13 +416,16 @@ def get_location(location: str): ) # Dewford Town + entrance = get_entrance("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE109/BEACH") set_rule( - get_entrance("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE109/BEACH"), + entrance, lambda state: state.can_reach("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN", "Entrance", world.player) and state.has("EVENT_TALK_TO_MR_STONE", world.player) and state.has("EVENT_DELIVER_LETTER", world.player) ) + world.multiworld.register_indirect_condition( + get_entrance("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN").parent_region, entrance) set_rule( get_entrance("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN"), lambda state: @@ -451,14 +454,17 @@ def get_location(location: str): ) # Route 109 + entrance = get_entrance("REGION_ROUTE109/BEACH -> REGION_DEWFORD_TOWN/MAIN") set_rule( - get_entrance("REGION_ROUTE109/BEACH -> REGION_DEWFORD_TOWN/MAIN"), + entrance, lambda state: state.can_reach("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN", "Entrance", world.player) and state.can_reach("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE109/BEACH", "Entrance", world.player) and state.has("EVENT_TALK_TO_MR_STONE", world.player) and state.has("EVENT_DELIVER_LETTER", world.player) ) + world.multiworld.register_indirect_condition( + get_entrance("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN").parent_region, entrance) set_rule( get_entrance("REGION_ROUTE109/BEACH -> REGION_ROUTE109/SEA"), hm_rules["HM03 Surf"] From 0fdc14bc42a8af64a17454c42d25f5c037e95309 Mon Sep 17 00:00:00 2001 From: Benjamin S Wolf Date: Sun, 15 Dec 2024 13:29:56 -0800 Subject: [PATCH 029/144] Core: Deduplicate exception output (#4036) When running Generate.py, uncaught exceptions are logged once to a file and twice to the console due to keeping the original excepthook. We can avoid this by filtering the file log out of the stream handler. --- Utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Utils.py b/Utils.py index 50adb18f42be..574c006b503d 100644 --- a/Utils.py +++ b/Utils.py @@ -534,7 +534,8 @@ def handle_exception(exc_type, exc_value, exc_traceback): sys.__excepthook__(exc_type, exc_value, exc_traceback) return logging.getLogger(exception_logger).exception("Uncaught exception", - exc_info=(exc_type, exc_value, exc_traceback)) + exc_info=(exc_type, exc_value, exc_traceback), + extra={"NoStream": exception_logger is None}) return orig_hook(exc_type, exc_value, exc_traceback) handle_exception._wrapped = True From 6282efb13c9842a2c01eb6551d23b5d448f2d91c Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sun, 15 Dec 2024 16:40:36 -0500 Subject: [PATCH 030/144] TUNIC: Additional Combat Logic Option (#3658) --- worlds/tunic/__init__.py | 45 ++- worlds/tunic/combat_logic.py | 422 +++++++++++++++++++++ worlds/tunic/er_data.py | 282 ++++++++++---- worlds/tunic/er_rules.py | 557 ++++++++++++++++++++++++---- worlds/tunic/er_scripts.py | 31 +- worlds/tunic/items.py | 70 ++-- worlds/tunic/ladder_storage_data.py | 13 +- worlds/tunic/locations.py | 96 ++--- worlds/tunic/options.py | 18 + worlds/tunic/rules.py | 25 +- worlds/tunic/test/test_access.py | 6 +- 11 files changed, 1309 insertions(+), 256 deletions(-) create mode 100644 worlds/tunic/combat_logic.py diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index 4c62b18b140f..29dbf150125c 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -1,7 +1,8 @@ from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union from logging import warning -from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld -from .items import item_name_to_id, item_table, item_name_groups, fool_tiers, filler_items, slot_data_item_names +from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState +from .items import (item_name_to_id, item_table, item_name_groups, fool_tiers, filler_items, slot_data_item_names, + combat_items) from .locations import location_table, location_name_groups, location_name_to_id, hexagon_locations from .rules import set_location_rules, set_region_rules, randomize_ability_unlocks, gold_hexagon from .er_rules import set_er_location_rules @@ -10,6 +11,7 @@ from .er_data import portal_mapping, RegionInfo, tunic_er_regions from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections, LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage) +from .combat_logic import area_data, CombatState from worlds.AutoWorld import WebWorld, World from Options import PlandoConnection from decimal import Decimal, ROUND_HALF_UP @@ -127,11 +129,21 @@ def generate_early(self) -> None: self.options.shuffle_ladders.value = passthrough["shuffle_ladders"] self.options.fixed_shop.value = self.options.fixed_shop.option_false self.options.laurels_location.value = self.options.laurels_location.option_anywhere + self.options.combat_logic.value = passthrough["combat_logic"] @classmethod def stage_generate_early(cls, multiworld: MultiWorld) -> None: tunic_worlds: Tuple[TunicWorld] = multiworld.get_game_worlds("TUNIC") for tunic in tunic_worlds: + # setting up state combat logic stuff, see has_combat_reqs for its use + # and this is magic so pycharm doesn't like it, unfortunately + if tunic.options.combat_logic: + multiworld.state.tunic_need_to_reset_combat_from_collect[tunic.player] = False + multiworld.state.tunic_need_to_reset_combat_from_remove[tunic.player] = False + multiworld.state.tunic_area_combat_state[tunic.player] = {} + for area_name in area_data.keys(): + multiworld.state.tunic_area_combat_state[tunic.player][area_name] = CombatState.unchecked + # if it's one of the options, then it isn't a custom seed group if tunic.options.entrance_rando.value in EntranceRando.options.values(): continue @@ -190,10 +202,12 @@ def stage_generate_early(cls, multiworld: MultiWorld) -> None: def create_item(self, name: str, classification: ItemClassification = None) -> TunicItem: item_data = item_table[name] - return TunicItem(name, classification or item_data.classification, self.item_name_to_id[name], self.player) + # if item_data.combat_ic is None, it'll take item_data.classification instead + itemclass: ItemClassification = ((item_data.combat_ic if self.options.combat_logic else None) + or item_data.classification) + return TunicItem(name, classification or itemclass, self.item_name_to_id[name], self.player) def create_items(self) -> None: - tunic_items: List[TunicItem] = [] self.slot_data_items = [] @@ -322,15 +336,15 @@ def create_regions(self) -> None: self.ability_unlocks["Pages 42-43 (Holy Cross)"] = passthrough["Hexagon Quest Holy Cross"] self.ability_unlocks["Pages 52-53 (Icebolt)"] = passthrough["Hexagon Quest Icebolt"] - # ladder rando uses ER with vanilla connections, so that we're not managing more rules files - if self.options.entrance_rando or self.options.shuffle_ladders: + # Ladders and Combat Logic uses ER rules with vanilla connections for easier maintenance + if self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic: portal_pairs = create_er_regions(self) if self.options.entrance_rando: # these get interpreted by the game to tell it which entrances to connect for portal1, portal2 in portal_pairs.items(): self.tunic_portal_pairs[portal1.scene_destination()] = portal2.scene_destination() else: - # for non-ER, non-ladders + # uses the original rules, easier to navigate and reference for region_name in tunic_regions: region = Region(region_name, self.player, self.multiworld) self.multiworld.regions.append(region) @@ -351,7 +365,8 @@ def create_regions(self) -> None: victory_region.locations.append(victory_location) def set_rules(self) -> None: - if self.options.entrance_rando or self.options.shuffle_ladders: + # same reason as in create_regions, could probably be put into create_regions + if self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic: set_er_location_rules(self) else: set_region_rules(self) @@ -360,6 +375,19 @@ def set_rules(self) -> None: def get_filler_item_name(self) -> str: return self.random.choice(filler_items) + # cache whether you can get through combat logic areas + def collect(self, state: CollectionState, item: Item) -> bool: + change = super().collect(state, item) + if change and self.options.combat_logic and item.name in combat_items: + state.tunic_need_to_reset_combat_from_collect[self.player] = True + return change + + def remove(self, state: CollectionState, item: Item) -> bool: + change = super().remove(state, item) + if change and self.options.combat_logic and item.name in combat_items: + state.tunic_need_to_reset_combat_from_remove[self.player] = True + return change + def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None: if self.options.entrance_rando: hint_data.update({self.player: {}}) @@ -426,6 +454,7 @@ def fill_slot_data(self) -> Dict[str, Any]: "maskless": self.options.maskless.value, "entrance_rando": int(bool(self.options.entrance_rando.value)), "shuffle_ladders": self.options.shuffle_ladders.value, + "combat_logic": self.options.combat_logic.value, "Hexagon Quest Prayer": self.ability_unlocks["Pages 24-25 (Prayer)"], "Hexagon Quest Holy Cross": self.ability_unlocks["Pages 42-43 (Holy Cross)"], "Hexagon Quest Icebolt": self.ability_unlocks["Pages 52-53 (Icebolt)"], diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py new file mode 100644 index 000000000000..9ff363942c9e --- /dev/null +++ b/worlds/tunic/combat_logic.py @@ -0,0 +1,422 @@ +from typing import Dict, List, NamedTuple, Tuple, Optional +from enum import IntEnum +from collections import defaultdict +from BaseClasses import CollectionState +from .rules import has_sword, has_melee +from worlds.AutoWorld import LogicMixin + + +# the vanilla stats you are expected to have to get through an area, based on where they are in vanilla +class AreaStats(NamedTuple): + att_level: int + def_level: int + potion_level: int # all 3 are before your first bonfire after getting the upgrade page, third costs 1k + hp_level: int + sp_level: int + mp_level: int + potion_count: int + equipment: List[str] = [] + is_boss: bool = False + + +# the vanilla upgrades/equipment you would have +area_data: Dict[str, AreaStats] = { + "Overworld": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Stick"]), + "East Forest": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Sword"]), + "Before Well": AreaStats(1, 1, 1, 1, 1, 1, 3, ["Sword", "Shield"]), + # learn how to upgrade + "Beneath the Well": AreaStats(2, 1, 3, 3, 1, 1, 3, ["Sword", "Shield"]), + "Dark Tomb": AreaStats(2, 2, 3, 3, 1, 1, 3, ["Sword", "Shield"]), + "West Garden": AreaStats(2, 3, 3, 3, 1, 1, 4, ["Sword", "Shield"]), + "Garden Knight": AreaStats(3, 3, 3, 3, 2, 1, 4, ["Sword", "Shield"], is_boss=True), + # get the wand here + "Beneath the Vault": AreaStats(3, 3, 3, 3, 2, 1, 4, ["Sword", "Shield", "Magic"]), + "Eastern Vault Fortress": AreaStats(3, 3, 3, 4, 3, 2, 4, ["Sword", "Shield", "Magic"]), + "Siege Engine": AreaStats(3, 3, 3, 4, 3, 2, 4, ["Sword", "Shield", "Magic"], is_boss=True), + "Frog's Domain": AreaStats(3, 4, 3, 5, 3, 3, 4, ["Sword", "Shield", "Magic"]), + # the second half of Atoll is the part you need the stats for, so putting it after frogs + "Ruined Atoll": AreaStats(4, 4, 3, 5, 3, 3, 5, ["Sword", "Shield", "Magic"]), + "The Librarian": AreaStats(4, 4, 3, 5, 3, 3, 5, ["Sword", "Shield", "Magic"], is_boss=True), + "Quarry": AreaStats(5, 4, 3, 5, 3, 3, 5, ["Sword", "Shield", "Magic"]), + "Rooted Ziggurat": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic"]), + "Boss Scavenger": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic"], is_boss=True), + "Swamp": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"]), + "Cathedral": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"]), + # marked as boss because the garden knights can't get hurt by stick + "Gauntlet": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"], is_boss=True), + "The Heir": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic", "Laurels"], is_boss=True), +} + + +# these are used for caching which areas can currently be reached in state +boss_areas: List[str] = [name for name, data in area_data.items() if data.is_boss and name != "Gauntlet"] +non_boss_areas: List[str] = [name for name, data in area_data.items() if not data.is_boss] + + +class CombatState(IntEnum): + unchecked = 0 + failed = 1 + succeeded = 2 + + +def has_combat_reqs(area_name: str, state: CollectionState, player: int) -> bool: + # we're caching whether you've met the combat reqs before if the state didn't change first + # if the combat state is stale, mark each area's combat state as stale + if state.tunic_need_to_reset_combat_from_collect[player]: + state.tunic_need_to_reset_combat_from_collect[player] = False + for name in area_data.keys(): + if state.tunic_area_combat_state[player][name] == CombatState.failed: + state.tunic_area_combat_state[player][name] = CombatState.unchecked + + if state.tunic_need_to_reset_combat_from_remove[player]: + state.tunic_need_to_reset_combat_from_remove[player] = False + for name in area_data.keys(): + if state.tunic_area_combat_state[player][name] == CombatState.succeeded: + state.tunic_area_combat_state[player][name] = CombatState.unchecked + + if state.tunic_area_combat_state[player][area_name] > CombatState.unchecked: + return state.tunic_area_combat_state[player][area_name] == CombatState.succeeded + + met_combat_reqs = check_combat_reqs(area_name, state, player) + + # we want to skip the "none area" since we don't record its results + if area_name not in area_data.keys(): + return met_combat_reqs + + # loop through the lists and set the easier/harder area states accordingly + if area_name in boss_areas: + area_list = boss_areas + elif area_name in non_boss_areas: + area_list = non_boss_areas + else: + area_list = [area_name] + + if met_combat_reqs: + # set the state as true for each area until you get to the area we're looking at + for name in area_list: + state.tunic_area_combat_state[player][name] = CombatState.succeeded + if name == area_name: + break + else: + # set the state as false for the area we're looking at and each area after that + reached_name = False + for name in area_list: + if name == area_name: + reached_name = True + if reached_name: + state.tunic_area_combat_state[player][name] = CombatState.failed + + return met_combat_reqs + + +def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_data: Optional[AreaStats] = None) -> bool: + data = alt_data or area_data[area_name] + extra_att_needed = 0 + extra_def_needed = 0 + extra_mp_needed = 0 + has_magic = state.has_any({"Magic Wand", "Gun"}, player) + stick_bool = False + sword_bool = False + for item in data.equipment: + if item == "Stick": + if not has_melee(state, player): + if has_magic: + # magic can make up for the lack of stick + extra_mp_needed += 2 + extra_att_needed -= 16 + else: + return False + else: + stick_bool = True + + elif item == "Sword": + if not has_sword(state, player): + # need sword for bosses + if data.is_boss: + return False + if has_magic: + # +4 mp pretty much makes up for the lack of sword, at least in Quarry + extra_mp_needed += 4 + # stick is a backup plan, and doesn't scale well, so let's require a little less + extra_att_needed -= 2 + elif has_melee(state, player): + # may revise this later based on feedback + extra_att_needed += 3 + extra_def_needed += 2 + else: + return False + else: + sword_bool = True + + elif item == "Shield": + if not state.has("Shield", player): + extra_def_needed += 2 + elif item == "Laurels": + if not state.has("Hero's Laurels", player): + # these are entirely based on vibes + extra_att_needed += 2 + extra_def_needed += 3 + elif item == "Magic": + if not has_magic: + extra_att_needed += 2 + extra_def_needed += 2 + extra_mp_needed -= 16 + modified_stats = AreaStats(data.att_level + extra_att_needed, data.def_level + extra_def_needed, data.potion_level, + data.hp_level, data.sp_level, data.mp_level + extra_mp_needed, data.potion_count) + if not has_required_stats(modified_stats, state, player): + # we may need to check if you would have the required stats if you were missing a weapon + # it's kinda janky, but these only get hit in less than once per 100 generations, so whatever + if sword_bool and "Sword" in data.equipment and "Magic" in data.equipment: + # we need to check if you would have the required stats if you didn't have melee + equip_list = [item for item in data.equipment if item != "Sword"] + more_modified_stats = AreaStats(data.att_level - 16, data.def_level, data.potion_level, + data.hp_level, data.sp_level, data.mp_level + 4, data.potion_count, + equip_list) + if check_combat_reqs("none", state, player, more_modified_stats): + return True + + # and we need to check if you would have the required stats if you didn't have magic + equip_list = [item for item in data.equipment if item != "Magic"] + more_modified_stats = AreaStats(data.att_level + 2, data.def_level + 2, data.potion_level, + data.hp_level, data.sp_level, data.mp_level - 16, data.potion_count, + equip_list) + if check_combat_reqs("none", state, player, more_modified_stats): + return True + return False + + elif stick_bool and "Stick" in data.equipment and "Magic" in data.equipment: + # we need to check if you would have the required stats if you didn't have the stick + equip_list = [item for item in data.equipment if item != "Stick"] + more_modified_stats = AreaStats(data.att_level - 16, data.def_level, data.potion_level, + data.hp_level, data.sp_level, data.mp_level + 4, data.potion_count, + equip_list) + if check_combat_reqs("none", state, player, more_modified_stats): + return True + return False + else: + return False + return True + + +# check if you have the required stats, and the money to afford them +# it may be innaccurate due to poor spending, and it may even require you to "spend poorly" +# but that's fine -- it's already pretty generous to begin with +def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> bool: + money_required = 0 + player_att = 0 + + # check if we actually need the stat before checking state + if data.att_level > 1: + player_att, att_offerings = get_att_level(state, player) + if player_att < data.att_level: + return False + else: + extra_att = player_att - data.att_level + paid_att = max(0, att_offerings - extra_att) + # attack upgrades cost 100 for the first, +50 for each additional + money_per_att = 100 + for _ in range(paid_att): + money_required += money_per_att + money_per_att += 50 + + # adding defense and sp together since they accomplish similar things: making you take less damage + if data.def_level + data.sp_level > 2: + player_def, def_offerings = get_def_level(state, player) + player_sp, sp_offerings = get_sp_level(state, player) + if player_def + player_sp < data.def_level + data.sp_level: + return False + else: + free_def = player_def - def_offerings + free_sp = player_sp - sp_offerings + paid_stats = data.def_level + data.sp_level - free_def - free_sp + sp_to_buy = 0 + + if paid_stats <= 0: + # if you don't have to pay for any stats, you don't need money for these upgrades + def_to_buy = 0 + elif paid_stats <= def_offerings: + # get the amount needed to buy these def offerings + def_to_buy = paid_stats + else: + def_to_buy = def_offerings + sp_to_buy = max(0, paid_stats - def_offerings) + + # if you have to buy more than 3 def, it's cheaper to buy 1 extra sp + if def_to_buy > 3 and sp_offerings > 0: + def_to_buy -= 1 + sp_to_buy += 1 + # def costs 100 for the first, +50 for each additional + money_per_def = 100 + for _ in range(def_to_buy): + money_required += money_per_def + money_per_def += 50 + # sp costs 200 for the first, +200 for each additional + money_per_sp = 200 + for _ in range(sp_to_buy): + money_required += money_per_sp + money_per_sp += 200 + + # if you have 2 more attack than needed, we can forego needing mp + if data.mp_level > 1 and player_att < data.att_level + 2: + player_mp, mp_offerings = get_mp_level(state, player) + if player_mp < data.mp_level: + return False + else: + extra_mp = player_mp - data.mp_level + paid_mp = max(0, mp_offerings - extra_mp) + # mp costs 300 for the first, +50 for each additional + money_per_mp = 300 + for _ in range(paid_mp): + money_required += money_per_mp + money_per_mp += 50 + + req_effective_hp = calc_effective_hp(data.hp_level, data.potion_level, data.potion_count) + player_potion, potion_offerings = get_potion_level(state, player) + player_hp, hp_offerings = get_hp_level(state, player) + player_potion_count = get_potion_count(state, player) + player_effective_hp = calc_effective_hp(player_hp, player_potion, player_potion_count) + if player_effective_hp < req_effective_hp: + return False + else: + # need a way to determine which of potion offerings or hp offerings you can reduce + # your level if you didn't pay for offerings + free_potion = player_potion - potion_offerings + free_hp = player_hp - hp_offerings + paid_hp_count = 0 + paid_potion_count = 0 + if calc_effective_hp(free_hp, free_potion, player_potion_count) >= req_effective_hp: + # you don't need to buy upgrades + pass + # if you have no potions, or no potion upgrades, you only need to check your hp upgrades + elif player_potion_count == 0 or potion_offerings == 0: + # check if you have enough hp at each paid hp offering + for i in range(hp_offerings): + paid_hp_count = i + 1 + if calc_effective_hp(paid_hp_count, 0, player_potion_count) > req_effective_hp: + break + else: + for i in range(potion_offerings): + paid_potion_count = i + 1 + if calc_effective_hp(free_hp, free_potion + paid_potion_count, player_potion_count) > req_effective_hp: + break + for j in range(hp_offerings): + paid_hp_count = j + 1 + if (calc_effective_hp(free_hp + paid_hp_count, free_potion + paid_potion_count, player_potion_count) + > req_effective_hp): + break + # hp costs 200 for the first, +50 for each additional + money_per_hp = 200 + for _ in range(paid_hp_count): + money_required += money_per_hp + money_per_hp += 50 + + # potion costs 100 for the first, 300 for the second, 1,000 for the third, and +200 for each additional + # currently we assume you will not buy past the second potion upgrade, but we might change our minds later + money_per_potion = 100 + for _ in range(paid_potion_count): + money_required += money_per_potion + if money_per_potion == 100: + money_per_potion = 300 + elif money_per_potion == 300: + money_per_potion = 1000 + else: + money_per_potion += 200 + + if money_required > get_money_count(state, player): + return False + + return True + + +# returns a tuple of your max attack level, the number of attack offerings +def get_att_level(state: CollectionState, player: int) -> Tuple[int, int]: + att_offerings = state.count("ATT Offering", player) + att_upgrades = state.count("Hero Relic - ATT", player) + sword_level = state.count("Sword Upgrade", player) + if sword_level >= 3: + att_upgrades += min(2, sword_level - 2) + # attack falls off, can just cap it at 8 for simplicity + return min(8, 1 + att_offerings + att_upgrades), att_offerings + + +# returns a tuple of your max defense level, the number of defense offerings +def get_def_level(state: CollectionState, player: int) -> Tuple[int, int]: + def_offerings = state.count("DEF Offering", player) + # defense falls off, can just cap it at 8 for simplicity + return (min(8, 1 + def_offerings + + state.count_from_list({"Hero Relic - DEF", "Secret Legend", "Phonomath"}, player)), + def_offerings) + + +# returns a tuple of your max potion level, the number of potion offerings +def get_potion_level(state: CollectionState, player: int) -> Tuple[int, int]: + potion_offerings = min(2, state.count("Potion Offering", player)) + # your third potion upgrade (from offerings) costs 1,000 money, reasonable to assume you won't do that + return (1 + potion_offerings + + state.count_from_list({"Hero Relic - POTION", "Just Some Pals", "Spring Falls", "Back To Work"}, player), + potion_offerings) + + +# returns a tuple of your max hp level, the number of hp offerings +def get_hp_level(state: CollectionState, player: int) -> Tuple[int, int]: + hp_offerings = state.count("HP Offering", player) + return 1 + hp_offerings + state.count("Hero Relic - HP", player), hp_offerings + + +# returns a tuple of your max sp level, the number of sp offerings +def get_sp_level(state: CollectionState, player: int) -> Tuple[int, int]: + sp_offerings = state.count("SP Offering", player) + return (1 + sp_offerings + + state.count_from_list({"Hero Relic - SP", "Mr Mayor", "Power Up", + "Regal Weasel", "Forever Friend"}, player), + sp_offerings) + + +def get_mp_level(state: CollectionState, player: int) -> Tuple[int, int]: + mp_offerings = state.count("MP Offering", player) + return (1 + mp_offerings + + state.count_from_list({"Hero Relic - MP", "Sacred Geometry", "Vintage", "Dusty"}, player), + mp_offerings) + + +def get_potion_count(state: CollectionState, player: int) -> int: + return state.count("Potion Flask", player) + state.count("Flask Shard", player) // 3 + + +def calc_effective_hp(hp_level: int, potion_level: int, potion_count: int) -> int: + player_hp = 60 + hp_level * 20 + # since you don't tend to use potions efficiently all the time, scale healing by .75 + total_healing = int(.75 * potion_count * min(player_hp, 20 + 10 * potion_level)) + return player_hp + total_healing + + +# returns the total amount of progression money the player has +def get_money_count(state: CollectionState, player: int) -> int: + money: int = 0 + # this could be done with something to parse the money count at the end of the string, but I don't wanna + money += state.count("Money x255", player) * 255 # 1 in pool + money += state.count("Money x200", player) * 200 # 1 in pool + money += state.count("Money x128", player) * 128 # 3 in pool + # total from regular money: 839 + # first effigy is 8, doubles until it reaches 512 at number 7, after effigy 28 they stop dropping money + # with the vanilla count of 12, you get 3,576 money from effigies + effigy_count = min(28, state.count("Effigy", player)) # 12 in pool + money_per_break = 8 + for _ in range(effigy_count): + money += money_per_break + money_per_break = min(512, money_per_break * 2) + return money + + +class TunicState(LogicMixin): + tunic_need_to_reset_combat_from_collect: Dict[int, bool] + tunic_need_to_reset_combat_from_remove: Dict[int, bool] + tunic_area_combat_state: Dict[int, Dict[str, int]] + + def init_mixin(self, _): + # the per-player need to reset the combat state when collecting a combat item + self.tunic_need_to_reset_combat_from_collect = defaultdict(lambda: False) + # the per-player need to reset the combat state when removing a combat item + self.tunic_need_to_reset_combat_from_remove = defaultdict(lambda: False) + # the per-player, per-area state of combat checking -- unchecked, failed, or succeeded + self.tunic_area_combat_state = defaultdict(lambda: defaultdict(lambda: CombatState.unchecked)) diff --git a/worlds/tunic/er_data.py b/worlds/tunic/er_data.py index 1269f3b85e45..9794f4a87b67 100644 --- a/worlds/tunic/er_data.py +++ b/worlds/tunic/er_data.py @@ -235,12 +235,12 @@ def destination_scene(self) -> str: # the vanilla connection destination="Sewer_Boss", tag="_"), Portal(name="Well Exit towards Furnace", region="Beneath the Well Back", destination="Overworld Redux", tag="_west_aqueduct"), - + Portal(name="Well Boss to Well", region="Well Boss", destination="Sewer", tag="_"), Portal(name="Checkpoint to Dark Tomb", region="Dark Tomb Checkpoint", destination="Crypt Redux", tag="_"), - + Portal(name="Dark Tomb to Overworld", region="Dark Tomb Entry Point", destination="Overworld Redux", tag="_"), Portal(name="Dark Tomb to Furnace", region="Dark Tomb Dark Exit", @@ -248,13 +248,13 @@ def destination_scene(self) -> str: # the vanilla connection Portal(name="Dark Tomb to Checkpoint", region="Dark Tomb Entry Point", destination="Sewer_Boss", tag="_"), - Portal(name="West Garden Exit near Hero's Grave", region="West Garden", + Portal(name="West Garden Exit near Hero's Grave", region="West Garden before Terry", destination="Overworld Redux", tag="_lower"), - Portal(name="West Garden to Magic Dagger House", region="West Garden", + Portal(name="West Garden to Magic Dagger House", region="West Garden at Dagger House", destination="archipelagos_house", tag="_"), Portal(name="West Garden Exit after Boss", region="West Garden after Boss", destination="Overworld Redux", tag="_upper"), - Portal(name="West Garden Shop", region="West Garden", + Portal(name="West Garden Shop", region="West Garden before Terry", destination="Shop", tag="_"), Portal(name="West Garden Laurels Exit", region="West Garden Laurels Exit Region", destination="Overworld Redux", tag="_lowest"), @@ -262,7 +262,7 @@ def destination_scene(self) -> str: # the vanilla connection destination="RelicVoid", tag="_teleporter_relic plinth"), Portal(name="West Garden to Far Shore", region="West Garden Portal", destination="Transit", tag="_teleporter_archipelagos_teleporter"), - + Portal(name="Magic Dagger House Exit", region="Magic Dagger House", destination="Archipelagos Redux", tag="_"), @@ -308,7 +308,7 @@ def destination_scene(self) -> str: # the vanilla connection Portal(name="East Fortress to Interior Upper", region="Fortress East Shortcut Upper", destination="Fortress Main", tag="_upper"), - Portal(name="Fortress Grave Path Lower Exit", region="Fortress Grave Path", + Portal(name="Fortress Grave Path Lower Exit", region="Fortress Grave Path Entry", destination="Fortress Courtyard", tag="_Lower"), Portal(name="Fortress Hero's Grave", region="Fortress Hero's Grave Region", destination="RelicVoid", tag="_teleporter_relic plinth"), @@ -339,7 +339,7 @@ def destination_scene(self) -> str: # the vanilla connection destination="Frog Stairs", tag="_eye"), Portal(name="Frog Stairs Mouth Entrance", region="Ruined Atoll Frog Mouth", destination="Frog Stairs", tag="_mouth"), - + Portal(name="Frog Stairs Eye Exit", region="Frog Stairs Eye Exit", destination="Atoll Redux", tag="_eye"), Portal(name="Frog Stairs Mouth Exit", region="Frog Stairs Upper", @@ -348,39 +348,39 @@ def destination_scene(self) -> str: # the vanilla connection destination="frog cave main", tag="_Entrance"), Portal(name="Frog Stairs to Frog's Domain's Exit", region="Frog Stairs Lower", destination="frog cave main", tag="_Exit"), - + Portal(name="Frog's Domain Ladder Exit", region="Frog's Domain Entry", destination="Frog Stairs", tag="_Entrance"), Portal(name="Frog's Domain Orb Exit", region="Frog's Domain Back", destination="Frog Stairs", tag="_Exit"), - + Portal(name="Library Exterior Tree", region="Library Exterior Tree Region", destination="Atoll Redux", tag="_"), Portal(name="Library Exterior Ladder", region="Library Exterior Ladder Region", destination="Library Hall", tag="_"), - + Portal(name="Library Hall Bookshelf Exit", region="Library Hall Bookshelf", destination="Library Exterior", tag="_"), Portal(name="Library Hero's Grave", region="Library Hero's Grave Region", destination="RelicVoid", tag="_teleporter_relic plinth"), Portal(name="Library Hall to Rotunda", region="Library Hall to Rotunda", destination="Library Rotunda", tag="_"), - + Portal(name="Library Rotunda Lower Exit", region="Library Rotunda to Hall", destination="Library Hall", tag="_"), Portal(name="Library Rotunda Upper Exit", region="Library Rotunda to Lab", destination="Library Lab", tag="_"), - + Portal(name="Library Lab to Rotunda", region="Library Lab Lower", destination="Library Rotunda", tag="_"), Portal(name="Library to Far Shore", region="Library Portal", destination="Transit", tag="_teleporter_library teleporter"), Portal(name="Library Lab to Librarian Arena", region="Library Lab to Librarian", destination="Library Arena", tag="_"), - + Portal(name="Librarian Arena Exit", region="Library Arena", destination="Library Lab", tag="_"), - + Portal(name="Stairs to Top of the Mountain", region="Lower Mountain Stairs", destination="Mountaintop", tag="_"), Portal(name="Mountain to Quarry", region="Lower Mountain", @@ -433,7 +433,7 @@ def destination_scene(self) -> str: # the vanilla connection Portal(name="Ziggurat Tower to Ziggurat Lower", region="Rooted Ziggurat Middle Bottom", destination="ziggurat2020_3", tag="_"), - Portal(name="Ziggurat Lower to Ziggurat Tower", region="Rooted Ziggurat Lower Front", + Portal(name="Ziggurat Lower to Ziggurat Tower", region="Rooted Ziggurat Lower Entry", destination="ziggurat2020_2", tag="_"), Portal(name="Ziggurat Portal Room Entrance", region="Rooted Ziggurat Portal Room Entrance", destination="ziggurat2020_FTRoom", tag="_"), @@ -461,7 +461,7 @@ def destination_scene(self) -> str: # the vanilla connection Portal(name="Swamp Hero's Grave", region="Swamp Hero's Grave Region", destination="RelicVoid", tag="_teleporter_relic plinth"), - Portal(name="Cathedral Main Exit", region="Cathedral", + Portal(name="Cathedral Main Exit", region="Cathedral Entry", destination="Swamp Redux 2", tag="_main"), Portal(name="Cathedral Elevator", region="Cathedral to Gauntlet", destination="Cathedral Arena", tag="_"), @@ -523,7 +523,6 @@ class RegionInfo(NamedTuple): game_scene: str # the name of the scene in the actual game dead_end: int = 0 # if a region has only one exit outlet_region: Optional[str] = None - is_fake_region: bool = False # gets the outlet region name if it exists, the region if it doesn't @@ -563,6 +562,8 @@ class DeadEnd(IntEnum): "Overworld to West Garden Upper": RegionInfo("Overworld Redux"), # usually leads to garden knight "Overworld to West Garden from Furnace": RegionInfo("Overworld Redux"), # isolated stairway with one chest "Overworld Well Ladder": RegionInfo("Overworld Redux"), # just the ladder entrance itself as a region + "Overworld Well Entry Area": RegionInfo("Overworld Redux"), # the page, the bridge, etc. + "Overworld Tunnel to Beach": RegionInfo("Overworld Redux"), # the tunnel with the chest "Overworld Beach": RegionInfo("Overworld Redux"), # from the two turrets to invisble maze, and lower atoll entry "Overworld Tunnel Turret": RegionInfo("Overworld Redux"), # the tunnel turret by the southwest beach ladder "Overworld to Atoll Upper": RegionInfo("Overworld Redux"), # the little ledge before the ladder @@ -624,14 +625,18 @@ class DeadEnd(IntEnum): "Beneath the Well Front": RegionInfo("Sewer"), # the front, to separate it from the weapon requirement in the mid "Beneath the Well Main": RegionInfo("Sewer"), # the main section of it, requires a weapon "Beneath the Well Back": RegionInfo("Sewer"), # the back two portals, and all 4 upper chests - "West Garden": RegionInfo("Archipelagos Redux"), + "West Garden before Terry": RegionInfo("Archipelagos Redux"), # the lower entry point, near hero grave + "West Garden after Terry": RegionInfo("Archipelagos Redux"), # after Terry, up until next chompignons + "West Garden at Dagger House": RegionInfo("Archipelagos Redux"), # just outside magic dagger house + "West Garden South Checkpoint": RegionInfo("Archipelagos Redux"), "Magic Dagger House": RegionInfo("archipelagos_house", dead_end=DeadEnd.all_cats), "West Garden Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted, outlet_region="West Garden by Portal"), "West Garden by Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted), "West Garden Portal Item": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted), "West Garden Laurels Exit Region": RegionInfo("Archipelagos Redux"), + "West Garden before Boss": RegionInfo("Archipelagos Redux"), # main west garden "West Garden after Boss": RegionInfo("Archipelagos Redux"), - "West Garden Hero's Grave Region": RegionInfo("Archipelagos Redux", outlet_region="West Garden"), + "West Garden Hero's Grave Region": RegionInfo("Archipelagos Redux", outlet_region="West Garden before Terry"), "Ruined Atoll": RegionInfo("Atoll Redux"), "Ruined Atoll Lower Entry Area": RegionInfo("Atoll Redux"), "Ruined Atoll Ladder Tops": RegionInfo("Atoll Redux"), # at the top of the 5 ladders in south Atoll @@ -643,8 +648,9 @@ class DeadEnd(IntEnum): "Frog Stairs Upper": RegionInfo("Frog Stairs"), "Frog Stairs Lower": RegionInfo("Frog Stairs"), "Frog Stairs to Frog's Domain": RegionInfo("Frog Stairs"), - "Frog's Domain Entry": RegionInfo("frog cave main"), - "Frog's Domain": RegionInfo("frog cave main"), + "Frog's Domain Entry": RegionInfo("frog cave main"), # just the ladder + "Frog's Domain Front": RegionInfo("frog cave main"), # before combat + "Frog's Domain Main": RegionInfo("frog cave main"), "Frog's Domain Back": RegionInfo("frog cave main"), "Library Exterior Tree Region": RegionInfo("Library Exterior", outlet_region="Library Exterior by Tree"), "Library Exterior by Tree": RegionInfo("Library Exterior"), @@ -658,8 +664,8 @@ class DeadEnd(IntEnum): "Library Rotunda to Lab": RegionInfo("Library Rotunda"), "Library Lab": RegionInfo("Library Lab"), "Library Lab Lower": RegionInfo("Library Lab"), - "Library Portal": RegionInfo("Library Lab", outlet_region="Library Lab on Portal Pad"), "Library Lab on Portal Pad": RegionInfo("Library Lab"), + "Library Portal": RegionInfo("Library Lab", outlet_region="Library Lab on Portal Pad"), "Library Lab to Librarian": RegionInfo("Library Lab"), "Library Arena": RegionInfo("Library Arena", dead_end=DeadEnd.all_cats), "Fortress Exterior from East Forest": RegionInfo("Fortress Courtyard"), @@ -675,10 +681,12 @@ class DeadEnd(IntEnum): "Eastern Vault Fortress Gold Door": RegionInfo("Fortress Main"), "Fortress East Shortcut Upper": RegionInfo("Fortress East"), "Fortress East Shortcut Lower": RegionInfo("Fortress East"), - "Fortress Grave Path": RegionInfo("Fortress Reliquary"), + "Fortress Grave Path Entry": RegionInfo("Fortress Reliquary"), + "Fortress Grave Path Combat": RegionInfo("Fortress Reliquary"), # the combat is basically just a barrier here + "Fortress Grave Path by Grave": RegionInfo("Fortress Reliquary"), "Fortress Grave Path Upper": RegionInfo("Fortress Reliquary", dead_end=DeadEnd.restricted), "Fortress Grave Path Dusty Entrance Region": RegionInfo("Fortress Reliquary"), - "Fortress Hero's Grave Region": RegionInfo("Fortress Reliquary", outlet_region="Fortress Grave Path"), + "Fortress Hero's Grave Region": RegionInfo("Fortress Reliquary", outlet_region="Fortress Grave Path by Grave"), "Fortress Leaf Piles": RegionInfo("Dusty", dead_end=DeadEnd.all_cats), "Fortress Arena": RegionInfo("Fortress Arena"), "Fortress Arena Portal": RegionInfo("Fortress Arena", outlet_region="Fortress Arena"), @@ -697,6 +705,7 @@ class DeadEnd(IntEnum): "Monastery Rope": RegionInfo("Quarry Redux"), "Lower Quarry": RegionInfo("Quarry Redux"), "Even Lower Quarry": RegionInfo("Quarry Redux"), + "Even Lower Quarry Isolated Chest": RegionInfo("Quarry Redux"), # a region for that one chest "Lower Quarry Zig Door": RegionInfo("Quarry Redux"), "Rooted Ziggurat Entry": RegionInfo("ziggurat2020_0"), "Rooted Ziggurat Upper Entry": RegionInfo("ziggurat2020_1"), @@ -704,13 +713,15 @@ class DeadEnd(IntEnum): "Rooted Ziggurat Upper Back": RegionInfo("ziggurat2020_1"), # after the administrator "Rooted Ziggurat Middle Top": RegionInfo("ziggurat2020_2"), "Rooted Ziggurat Middle Bottom": RegionInfo("ziggurat2020_2"), - "Rooted Ziggurat Lower Front": RegionInfo("ziggurat2020_3"), # the vanilla entry point side + "Rooted Ziggurat Lower Entry": RegionInfo("ziggurat2020_3"), # the vanilla entry point side + "Rooted Ziggurat Lower Front": RegionInfo("ziggurat2020_3"), # the front for combat logic + "Rooted Ziggurat Lower Mid Checkpoint": RegionInfo("ziggurat2020_3"), # the mid-checkpoint before double admin "Rooted Ziggurat Lower Back": RegionInfo("ziggurat2020_3"), # the boss side - "Zig Skip Exit": RegionInfo("ziggurat2020_3", dead_end=DeadEnd.special, outlet_region="Rooted Ziggurat Lower Front"), # the exit from zig skip, for use with fixed shop on + "Zig Skip Exit": RegionInfo("ziggurat2020_3", dead_end=DeadEnd.special, outlet_region="Rooted Ziggurat Lower Entry"), # for use with fixed shop on "Rooted Ziggurat Portal Room Entrance": RegionInfo("ziggurat2020_3", outlet_region="Rooted Ziggurat Lower Back"), # the door itself on the zig 3 side "Rooted Ziggurat Portal": RegionInfo("ziggurat2020_FTRoom", outlet_region="Rooted Ziggurat Portal Room"), "Rooted Ziggurat Portal Room": RegionInfo("ziggurat2020_FTRoom"), - "Rooted Ziggurat Portal Room Exit": RegionInfo("ziggurat2020_FTRoom"), + "Rooted Ziggurat Portal Room Exit": RegionInfo("ziggurat2020_FTRoom", outlet_region="Rooted Ziggurat Portal Room"), "Swamp Front": RegionInfo("Swamp Redux 2"), # from the main entry to the top of the ladder after south "Swamp Mid": RegionInfo("Swamp Redux 2"), # from the bottom of the ladder to the cathedral door "Swamp Ledge under Cathedral Door": RegionInfo("Swamp Redux 2"), # the ledge with the chest and secret door @@ -719,7 +730,8 @@ class DeadEnd(IntEnum): "Back of Swamp": RegionInfo("Swamp Redux 2"), # the area with hero grave and gauntlet entrance "Swamp Hero's Grave Region": RegionInfo("Swamp Redux 2", outlet_region="Back of Swamp"), "Back of Swamp Laurels Area": RegionInfo("Swamp Redux 2"), # the spots you need laurels to traverse - "Cathedral": RegionInfo("Cathedral Redux"), + "Cathedral Entry": RegionInfo("Cathedral Redux"), # the checkpoint and easily-accessible chests + "Cathedral Main": RegionInfo("Cathedral Redux"), # the majority of Cathedral "Cathedral to Gauntlet": RegionInfo("Cathedral Redux"), # the elevator "Cathedral Secret Legend Room": RegionInfo("Cathedral Redux", dead_end=DeadEnd.all_cats), "Cathedral Gauntlet Checkpoint": RegionInfo("Cathedral Arena"), @@ -741,7 +753,7 @@ class DeadEnd(IntEnum): "Purgatory": RegionInfo("Purgatory"), "Shop": RegionInfo("Shop", dead_end=DeadEnd.all_cats), "Spirit Arena": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats), - "Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats) + "Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats), } @@ -759,6 +771,8 @@ class DeadEnd(IntEnum): "Overworld": { "Overworld Beach": [], + "Overworld Tunnel to Beach": + [], "Overworld to Atoll Upper": [["Hyperdash"]], "Overworld Belltower": @@ -769,7 +783,7 @@ class DeadEnd(IntEnum): [], "Overworld Special Shop Entry": [["Hyperdash"], ["LS1"]], - "Overworld Well Ladder": + "Overworld Well Entry Area": [], "Overworld Ruined Passage Door": [], @@ -847,6 +861,12 @@ class DeadEnd(IntEnum): # "Overworld": # [], # }, + "Overworld Tunnel to Beach": { + # "Overworld": + # [], + "Overworld Beach": + [], + }, "Overworld Beach": { # "Overworld": # [], @@ -873,9 +893,15 @@ class DeadEnd(IntEnum): "Overworld Beach": [], }, - "Overworld Well Ladder": { + "Overworld Well Entry Area": { # "Overworld": # [], + "Overworld Well Ladder": + [], + }, + "Overworld Well Ladder": { + "Overworld Well Entry Area": + [], }, "Overworld at Patrol Cave": { "East Overworld": @@ -954,6 +980,7 @@ class DeadEnd(IntEnum): "Overworld": [], }, + "Old House Front": { "Old House Back": [], @@ -962,6 +989,7 @@ class DeadEnd(IntEnum): "Old House Front": [["Hyperdash", "Zip"]], }, + "Furnace Fuse": { "Furnace Ladder Area": [["Hyperdash"]], @@ -976,6 +1004,7 @@ class DeadEnd(IntEnum): "Furnace Ladder Area": [["Hyperdash"]], }, + "Sealed Temple": { "Sealed Temple Rafters": [], @@ -984,10 +1013,12 @@ class DeadEnd(IntEnum): "Sealed Temple": [["Hyperdash"]], }, + "Hourglass Cave": { "Hourglass Cave Tower": [], }, + "Forest Belltower Upper": { "Forest Belltower Main": [], @@ -996,6 +1027,7 @@ class DeadEnd(IntEnum): "Forest Belltower Lower": [], }, + "East Forest": { "East Forest Dance Fox Spot": [["Hyperdash"], ["IG1"], ["LS1"]], @@ -1016,6 +1048,7 @@ class DeadEnd(IntEnum): "East Forest": [], }, + "Guard House 1 East": { "Guard House 1 West": [], @@ -1024,6 +1057,7 @@ class DeadEnd(IntEnum): "Guard House 1 East": [["Hyperdash"], ["LS1"]], }, + "Guard House 2 Upper": { "Guard House 2 Lower": [], @@ -1032,6 +1066,7 @@ class DeadEnd(IntEnum): "Guard House 2 Upper": [], }, + "Forest Grave Path Main": { "Forest Grave Path Upper": [["Hyperdash"], ["LS2"], ["IG3"]], @@ -1044,7 +1079,7 @@ class DeadEnd(IntEnum): }, "Forest Grave Path by Grave": { "Forest Hero's Grave": - [], + [], "Forest Grave Path Main": [["IG1"]], }, @@ -1052,6 +1087,7 @@ class DeadEnd(IntEnum): "Forest Grave Path by Grave": [], }, + "Beneath the Well Ladder Exit": { "Beneath the Well Front": [], @@ -1072,6 +1108,7 @@ class DeadEnd(IntEnum): "Beneath the Well Main": [], }, + "Well Boss": { "Dark Tomb Checkpoint": [], @@ -1080,6 +1117,7 @@ class DeadEnd(IntEnum): "Well Boss": [["Hyperdash", "Zip"]], }, + "Dark Tomb Entry Point": { "Dark Tomb Upper": [], @@ -1100,44 +1138,72 @@ class DeadEnd(IntEnum): "Dark Tomb Main": [], }, - "West Garden": { + + "West Garden before Terry": { + "West Garden after Terry": + [], + "West Garden Hero's Grave Region": + [], + }, + "West Garden Hero's Grave Region": { + "West Garden before Terry": + [], + }, + "West Garden after Terry": { + "West Garden before Terry": + [], + "West Garden South Checkpoint": + [], "West Garden Laurels Exit Region": - [["Hyperdash"], ["LS1"]], + [["LS1"]], + }, + "West Garden South Checkpoint": { + "West Garden before Boss": + [], + "West Garden at Dagger House": + [], + "West Garden after Terry": + [], + }, + "West Garden before Boss": { "West Garden after Boss": - [], - "West Garden Hero's Grave Region": + [], + "West Garden South Checkpoint": + [], + }, + "West Garden after Boss": { + "West Garden before Boss": + [["Hyperdash"]], + }, + "West Garden at Dagger House": { + "West Garden Laurels Exit Region": + [["Hyperdash"]], + "West Garden South Checkpoint": [], "West Garden Portal Item": [["IG2"]], }, "West Garden Laurels Exit Region": { - "West Garden": - [["Hyperdash"]], - }, - "West Garden after Boss": { - "West Garden": + "West Garden at Dagger House": [["Hyperdash"]], }, "West Garden Portal Item": { - "West Garden": + "West Garden at Dagger House": [["IG1"]], "West Garden by Portal": [["Hyperdash"]], }, "West Garden by Portal": { + "West Garden Portal": + [["West Garden South Checkpoint"]], "West Garden Portal Item": [["Hyperdash"]], - "West Garden Portal": - [["West Garden"]], }, "West Garden Portal": { "West Garden by Portal": [], }, - "West Garden Hero's Grave Region": { - "West Garden": - [], - }, + "Ruined Atoll": { "Ruined Atoll Lower Entry Area": [["Hyperdash"], ["LS1"]], @@ -1176,6 +1242,7 @@ class DeadEnd(IntEnum): "Ruined Atoll": [], }, + "Frog Stairs Eye Exit": { "Frog Stairs Upper": [], @@ -1196,16 +1263,25 @@ class DeadEnd(IntEnum): "Frog Stairs Lower": [], }, + "Frog's Domain Entry": { - "Frog's Domain": + "Frog's Domain Front": [], }, - "Frog's Domain": { + "Frog's Domain Front": { "Frog's Domain Entry": [], + "Frog's Domain Main": + [], + }, + "Frog's Domain Main": { + "Frog's Domain Front": + [], "Frog's Domain Back": [], }, + + # cannot get from frogs back to front "Library Exterior Ladder Region": { "Library Exterior by Tree": [], @@ -1220,6 +1296,7 @@ class DeadEnd(IntEnum): "Library Exterior by Tree": [], }, + "Library Hall Bookshelf": { "Library Hall": [], @@ -1240,6 +1317,7 @@ class DeadEnd(IntEnum): "Library Hall": [], }, + "Library Rotunda to Hall": { "Library Rotunda": [], @@ -1281,9 +1359,10 @@ class DeadEnd(IntEnum): "Library Lab": [], }, + "Fortress Exterior from East Forest": { "Fortress Exterior from Overworld": - [], + [], "Fortress Courtyard Upper": [["LS2"]], "Fortress Courtyard": @@ -1291,9 +1370,9 @@ class DeadEnd(IntEnum): }, "Fortress Exterior from Overworld": { "Fortress Exterior from East Forest": - [["Hyperdash"]], + [["Hyperdash"]], "Fortress Exterior near cave": - [], + [], "Fortress Courtyard": [["Hyperdash"], ["IG1"], ["LS1"]], }, @@ -1321,6 +1400,7 @@ class DeadEnd(IntEnum): "Fortress Courtyard": [], }, + "Beneath the Vault Ladder Exit": { "Beneath the Vault Main": [], @@ -1337,6 +1417,7 @@ class DeadEnd(IntEnum): "Beneath the Vault Ladder Exit": [], }, + "Fortress East Shortcut Lower": { "Fortress East Shortcut Upper": [["IG1"]], @@ -1345,6 +1426,7 @@ class DeadEnd(IntEnum): "Fortress East Shortcut Lower": [], }, + "Eastern Vault Fortress": { "Eastern Vault Fortress Gold Door": [["IG2"], ["Fortress Exterior from Overworld", "Beneath the Vault Back", "Fortress Courtyard Upper"]], @@ -1353,24 +1435,44 @@ class DeadEnd(IntEnum): "Eastern Vault Fortress": [["IG1"]], }, - "Fortress Grave Path": { + + "Fortress Grave Path Entry": { + "Fortress Grave Path Combat": + [], + # redundant here, keeping a comment to show it's intentional + # "Fortress Grave Path Dusty Entrance Region": + # [["Hyperdash"]], + }, + "Fortress Grave Path Combat": { + "Fortress Grave Path Entry": + [], + "Fortress Grave Path by Grave": + [], + }, + "Fortress Grave Path by Grave": { + "Fortress Grave Path Entry": + [], + # unnecessary, you can just skip it + # "Fortress Grave Path Combat": + # [], "Fortress Hero's Grave Region": - [], + [], "Fortress Grave Path Dusty Entrance Region": [["Hyperdash"]], }, "Fortress Grave Path Upper": { - "Fortress Grave Path": + "Fortress Grave Path Entry": [["IG1"]], }, "Fortress Grave Path Dusty Entrance Region": { - "Fortress Grave Path": + "Fortress Grave Path by Grave": [["Hyperdash"]], }, "Fortress Hero's Grave Region": { - "Fortress Grave Path": + "Fortress Grave Path by Grave": [], }, + "Fortress Arena": { "Fortress Arena Portal": [["Fortress Exterior from Overworld", "Beneath the Vault Back", "Eastern Vault Fortress"]], @@ -1379,6 +1481,7 @@ class DeadEnd(IntEnum): "Fortress Arena": [], }, + "Lower Mountain": { "Lower Mountain Stairs": [], @@ -1387,6 +1490,7 @@ class DeadEnd(IntEnum): "Lower Mountain": [], }, + "Monastery Back": { "Monastery Front": [["Hyperdash", "Zip"]], @@ -1401,6 +1505,7 @@ class DeadEnd(IntEnum): "Monastery Back": [], }, + "Quarry Entry": { "Quarry Portal": [["Quarry Connector"]], @@ -1436,15 +1541,17 @@ class DeadEnd(IntEnum): [], "Quarry Monastery Entry": [], - "Lower Quarry Zig Door": - [["IG3"]], }, "Lower Quarry": { "Even Lower Quarry": [], }, "Even Lower Quarry": { - "Lower Quarry": + "Even Lower Quarry Isolated Chest": + [], + }, + "Even Lower Quarry Isolated Chest": { + "Even Lower Quarry": [], "Lower Quarry Zig Door": [["Quarry", "Quarry Connector"], ["IG3"]], @@ -1453,6 +1560,7 @@ class DeadEnd(IntEnum): "Quarry Back": [], }, + "Rooted Ziggurat Upper Entry": { "Rooted Ziggurat Upper Front": [], @@ -1465,17 +1573,38 @@ class DeadEnd(IntEnum): "Rooted Ziggurat Upper Front": [["Hyperdash"]], }, + "Rooted Ziggurat Middle Top": { "Rooted Ziggurat Middle Bottom": [], }, + + "Rooted Ziggurat Lower Entry": { + "Rooted Ziggurat Lower Front": + [], + # can zip through to the checkpoint + "Rooted Ziggurat Lower Mid Checkpoint": + [["Hyperdash"]], + }, "Rooted Ziggurat Lower Front": { + "Rooted Ziggurat Lower Entry": + [], + "Rooted Ziggurat Lower Mid Checkpoint": + [], + }, + "Rooted Ziggurat Lower Mid Checkpoint": { + "Rooted Ziggurat Lower Entry": + [["Hyperdash"]], + "Rooted Ziggurat Lower Front": + [], "Rooted Ziggurat Lower Back": [], }, "Rooted Ziggurat Lower Back": { - "Rooted Ziggurat Lower Front": - [["Hyperdash"], ["LS2"], ["IG1"]], + "Rooted Ziggurat Lower Entry": + [["LS2"]], + "Rooted Ziggurat Lower Mid Checkpoint": + [["Hyperdash"], ["IG1"]], "Rooted Ziggurat Portal Room Entrance": [], }, @@ -1487,20 +1616,22 @@ class DeadEnd(IntEnum): "Rooted Ziggurat Lower Back": [], }, + "Rooted Ziggurat Portal Room Exit": { "Rooted Ziggurat Portal Room": [], }, "Rooted Ziggurat Portal Room": { - "Rooted Ziggurat Portal": - [], "Rooted Ziggurat Portal Room Exit": [["Rooted Ziggurat Lower Back"]], + "Rooted Ziggurat Portal": + [], }, "Rooted Ziggurat Portal": { "Rooted Ziggurat Portal Room": [], }, + "Swamp Front": { "Swamp Mid": [], @@ -1557,14 +1688,26 @@ class DeadEnd(IntEnum): "Back of Swamp": [], }, - "Cathedral": { + + "Cathedral Entry": { + "Cathedral to Gauntlet": + [], + "Cathedral Main": + [], + }, + "Cathedral Main": { + "Cathedral Entry": + [], "Cathedral to Gauntlet": [], }, "Cathedral to Gauntlet": { - "Cathedral": + "Cathedral Entry": + [], + "Cathedral Main": [], }, + "Cathedral Gauntlet Checkpoint": { "Cathedral Gauntlet": [], @@ -1577,6 +1720,7 @@ class DeadEnd(IntEnum): "Cathedral Gauntlet": [["Hyperdash"]], }, + "Far Shore": { "Far Shore to Spawn Region": [["Hyperdash"]], @@ -1587,7 +1731,7 @@ class DeadEnd(IntEnum): "Far Shore to Library Region": [["Library Lab"]], "Far Shore to West Garden Region": - [["West Garden"]], + [["West Garden South Checkpoint"]], "Far Shore to Fortress Region": [["Fortress Exterior from Overworld", "Beneath the Vault Back", "Eastern Vault Fortress"]], }, diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 786af0d617a8..163523108345 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -1,10 +1,11 @@ from typing import Dict, FrozenSet, Tuple, TYPE_CHECKING -from worlds.generic.Rules import set_rule, forbid_item -from .options import IceGrappling, LadderStorage -from .rules import (has_ability, has_sword, has_stick, has_ice_grapple_logic, has_lantern, has_mask, can_ladder_storage, +from worlds.generic.Rules import set_rule, add_rule, forbid_item +from .options import IceGrappling, LadderStorage, CombatLogic +from .rules import (has_ability, has_sword, has_melee, has_ice_grapple_logic, has_lantern, has_mask, can_ladder_storage, laurels_zip, bomb_walls) from .er_data import Portal, get_portal_outlet_region from .ladder_storage_data import ow_ladder_groups, region_ladders, easy_ls, medium_ls, hard_ls +from .combat_logic import has_combat_reqs from BaseClasses import Region, CollectionState if TYPE_CHECKING: @@ -43,6 +44,24 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ player = world.player options = world.options + # input scene destination tag, returns portal's name and paired portal's outlet region or region + def get_portal_info(portal_sd: str) -> Tuple[str, str]: + for portal1, portal2 in portal_pairs.items(): + if portal1.scene_destination() == portal_sd: + return portal1.name, get_portal_outlet_region(portal2, world) + if portal2.scene_destination() == portal_sd: + return portal2.name, get_portal_outlet_region(portal1, world) + raise Exception("No matches found in get_portal_info") + + # input scene destination tag, returns paired portal's name and region + def get_paired_portal(portal_sd: str) -> Tuple[str, str]: + for portal1, portal2 in portal_pairs.items(): + if portal1.scene_destination() == portal_sd: + return portal2.name, portal2.region + if portal2.scene_destination() == portal_sd: + return portal1.name, portal1.region + raise Exception("no matches found in get_paired_portal") + regions["Menu"].connect( connecting_region=regions["Overworld"]) @@ -56,10 +75,18 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ connecting_region=regions["Overworld Beach"], rule=lambda state: has_ladder("Ladders in Overworld Town", state, world) or state.has_any({laurels, grapple}, player)) + # regions["Overworld Beach"].connect( + # connecting_region=regions["Overworld"], + # rule=lambda state: has_ladder("Ladders in Overworld Town", state, world) + # or state.has_any({laurels, grapple}, player)) + + # region for combat logic, no need to connect it to beach since it would be the same as the ow -> beach cxn + ow_tunnel_beach = regions["Overworld"].connect( + connecting_region=regions["Overworld Tunnel to Beach"]) + regions["Overworld Beach"].connect( - connecting_region=regions["Overworld"], - rule=lambda state: has_ladder("Ladders in Overworld Town", state, world) - or state.has_any({laurels, grapple}, player)) + connecting_region=regions["Overworld Tunnel to Beach"], + rule=lambda state: state.has(laurels, player) or has_ladder("Ladders in Overworld Town", state, world)) regions["Overworld Beach"].connect( connecting_region=regions["Overworld West Garden Laurels Entry"], @@ -277,11 +304,17 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ connecting_region=regions["East Overworld"], rule=lambda state: state.has(laurels, player)) - regions["Overworld"].connect( + # region made for combat logic + ow_to_well_entry = regions["Overworld"].connect( + connecting_region=regions["Overworld Well Entry Area"]) + regions["Overworld Well Entry Area"].connect( + connecting_region=regions["Overworld"]) + + regions["Overworld Well Entry Area"].connect( connecting_region=regions["Overworld Well Ladder"], rule=lambda state: has_ladder("Ladders in Well", state, world)) regions["Overworld Well Ladder"].connect( - connecting_region=regions["Overworld"], + connecting_region=regions["Overworld Well Entry Area"], rule=lambda state: has_ladder("Ladders in Well", state, world)) # nmg: can ice grapple through the door @@ -306,7 +339,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Overworld Fountain Cross Door"].connect( connecting_region=regions["Overworld"]) - regions["Overworld"].connect( + ow_to_town_portal = regions["Overworld"].connect( connecting_region=regions["Overworld Town Portal"], rule=lambda state: has_ability(prayer, state, world)) regions["Overworld Town Portal"].connect( @@ -337,6 +370,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ rule=lambda state: has_ladder("Ladders in Overworld Town", state, world) or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world)) + # don't need the ice grapple rule since you can go from ow -> beach -> tunnel regions["Overworld"].connect( connecting_region=regions["Overworld Tunnel Turret"], rule=lambda state: state.has(laurels, player)) @@ -473,29 +507,28 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ connecting_region=regions["Beneath the Well Ladder Exit"], rule=lambda state: has_ladder("Ladders in Well", state, world)) - regions["Beneath the Well Front"].connect( + btw_front_main = regions["Beneath the Well Front"].connect( connecting_region=regions["Beneath the Well Main"], - rule=lambda state: has_stick(state, player) or state.has(fire_wand, player)) + rule=lambda state: has_melee(state, player) or state.has(fire_wand, player)) regions["Beneath the Well Main"].connect( - connecting_region=regions["Beneath the Well Front"], - rule=lambda state: has_stick(state, player) or state.has(fire_wand, player)) + connecting_region=regions["Beneath the Well Front"]) regions["Beneath the Well Main"].connect( connecting_region=regions["Beneath the Well Back"], rule=lambda state: has_ladder("Ladders in Well", state, world)) - regions["Beneath the Well Back"].connect( + btw_back_main = regions["Beneath the Well Back"].connect( connecting_region=regions["Beneath the Well Main"], rule=lambda state: has_ladder("Ladders in Well", state, world) - and (has_stick(state, player) or state.has(fire_wand, player))) + and (has_melee(state, player) or state.has(fire_wand, player))) - regions["Well Boss"].connect( + well_boss_to_dt = regions["Well Boss"].connect( connecting_region=regions["Dark Tomb Checkpoint"]) # can laurels through the gate, no setup needed regions["Dark Tomb Checkpoint"].connect( connecting_region=regions["Well Boss"], rule=lambda state: laurels_zip(state, world)) - regions["Dark Tomb Entry Point"].connect( + dt_entry_to_upper = regions["Dark Tomb Entry Point"].connect( connecting_region=regions["Dark Tomb Upper"], rule=lambda state: has_lantern(state, world)) regions["Dark Tomb Upper"].connect( @@ -512,34 +545,57 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Dark Tomb Main"].connect( connecting_region=regions["Dark Tomb Dark Exit"]) - regions["Dark Tomb Dark Exit"].connect( + dt_exit_to_main = regions["Dark Tomb Dark Exit"].connect( connecting_region=regions["Dark Tomb Main"], rule=lambda state: has_lantern(state, world)) # West Garden + # combat logic regions + wg_before_to_after_terry = regions["West Garden before Terry"].connect( + connecting_region=regions["West Garden after Terry"]) + wg_after_to_before_terry = regions["West Garden after Terry"].connect( + connecting_region=regions["West Garden before Terry"]) + + regions["West Garden after Terry"].connect( + connecting_region=regions["West Garden South Checkpoint"]) + wg_checkpoint_to_after_terry = regions["West Garden South Checkpoint"].connect( + connecting_region=regions["West Garden after Terry"]) + + wg_checkpoint_to_dagger = regions["West Garden South Checkpoint"].connect( + connecting_region=regions["West Garden at Dagger House"]) + regions["West Garden at Dagger House"].connect( + connecting_region=regions["West Garden South Checkpoint"]) + + wg_checkpoint_to_before_boss = regions["West Garden South Checkpoint"].connect( + connecting_region=regions["West Garden before Boss"]) + regions["West Garden before Boss"].connect( + connecting_region=regions["West Garden South Checkpoint"]) + regions["West Garden Laurels Exit Region"].connect( - connecting_region=regions["West Garden"], + connecting_region=regions["West Garden at Dagger House"], rule=lambda state: state.has(laurels, player)) - regions["West Garden"].connect( + regions["West Garden at Dagger House"].connect( connecting_region=regions["West Garden Laurels Exit Region"], rule=lambda state: state.has(laurels, player)) - # you can grapple Garden Knight to aggro it, then ledge it - regions["West Garden after Boss"].connect( - connecting_region=regions["West Garden"], + # laurels past, or ice grapple it off, or ice grapple to it then fight + after_gk_to_wg = regions["West Garden after Boss"].connect( + connecting_region=regions["West Garden before Boss"], rule=lambda state: state.has(laurels, player) - or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)) + or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world) + or (has_ice_grapple_logic(False, IceGrappling.option_easy, state, world) + and has_sword(state, player))) # ice grapple push Garden Knight off the side - regions["West Garden"].connect( + wg_to_after_gk = regions["West Garden before Boss"].connect( connecting_region=regions["West Garden after Boss"], rule=lambda state: state.has(laurels, player) or has_sword(state, player) or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)) - regions["West Garden"].connect( + regions["West Garden before Terry"].connect( connecting_region=regions["West Garden Hero's Grave Region"], rule=lambda state: has_ability(prayer, state, world)) regions["West Garden Hero's Grave Region"].connect( - connecting_region=regions["West Garden"]) + connecting_region=regions["West Garden before Terry"]) regions["West Garden Portal"].connect( connecting_region=regions["West Garden by Portal"]) @@ -556,9 +612,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ # can ice grapple to and from the item behind the magic dagger house regions["West Garden Portal Item"].connect( - connecting_region=regions["West Garden"], + connecting_region=regions["West Garden at Dagger House"], rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_easy, state, world)) - regions["West Garden"].connect( + regions["West Garden at Dagger House"].connect( connecting_region=regions["West Garden Portal Item"], rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_medium, state, world)) @@ -596,7 +652,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Ruined Atoll Portal"].connect( connecting_region=regions["Ruined Atoll"]) - regions["Ruined Atoll"].connect( + atoll_statue = regions["Ruined Atoll"].connect( connecting_region=regions["Ruined Atoll Statue"], rule=lambda state: has_ability(prayer, state, world) and (has_ladder("Ladders in South Atoll", state, world) @@ -629,10 +685,13 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ rule=lambda state: has_ladder("Ladders to Frog's Domain", state, world)) regions["Frog's Domain Entry"].connect( - connecting_region=regions["Frog's Domain"], + connecting_region=regions["Frog's Domain Front"], rule=lambda state: has_ladder("Ladders to Frog's Domain", state, world)) - regions["Frog's Domain"].connect( + frogs_front_to_main = regions["Frog's Domain Front"].connect( + connecting_region=regions["Frog's Domain Main"]) + + regions["Frog's Domain Main"].connect( connecting_region=regions["Frog's Domain Back"], rule=lambda state: state.has(grapple, player)) @@ -752,7 +811,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ rule=lambda state: state.has(laurels, player) or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world)) - regions["Fortress Courtyard Upper"].connect( + fort_upper_lower = regions["Fortress Courtyard Upper"].connect( connecting_region=regions["Fortress Courtyard"]) # nmg: can ice grapple to the upper ledge regions["Fortress Courtyard"].connect( @@ -762,12 +821,12 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Fortress Courtyard Upper"].connect( connecting_region=regions["Fortress Exterior from Overworld"]) - regions["Beneath the Vault Ladder Exit"].connect( + btv_front_to_main = regions["Beneath the Vault Ladder Exit"].connect( connecting_region=regions["Beneath the Vault Main"], rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, world) and has_lantern(state, world) # there's some boxes in the way - and (has_stick(state, player) or state.has_any((gun, grapple, fire_wand, laurels), player))) + and (has_melee(state, player) or state.has_any((gun, grapple, fire_wand, laurels), player))) # on the reverse trip, you can lure an enemy over to break the boxes if needed regions["Beneath the Vault Main"].connect( connecting_region=regions["Beneath the Vault Ladder Exit"], @@ -775,11 +834,11 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Beneath the Vault Main"].connect( connecting_region=regions["Beneath the Vault Back"]) - regions["Beneath the Vault Back"].connect( + btv_back_to_main = regions["Beneath the Vault Back"].connect( connecting_region=regions["Beneath the Vault Main"], rule=lambda state: has_lantern(state, world)) - regions["Fortress East Shortcut Upper"].connect( + fort_east_upper_lower = regions["Fortress East Shortcut Upper"].connect( connecting_region=regions["Fortress East Shortcut Lower"]) regions["Fortress East Shortcut Lower"].connect( connecting_region=regions["Fortress East Shortcut Upper"], @@ -794,21 +853,31 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ connecting_region=regions["Eastern Vault Fortress"], rule=lambda state: has_ice_grapple_logic(False, IceGrappling.option_easy, state, world)) - regions["Fortress Grave Path"].connect( - connecting_region=regions["Fortress Grave Path Dusty Entrance Region"], - rule=lambda state: state.has(laurels, player)) - regions["Fortress Grave Path Dusty Entrance Region"].connect( - connecting_region=regions["Fortress Grave Path"], - rule=lambda state: state.has(laurels, player)) + fort_grave_entry_to_combat = regions["Fortress Grave Path Entry"].connect( + connecting_region=regions["Fortress Grave Path Combat"]) + regions["Fortress Grave Path Combat"].connect( + connecting_region=regions["Fortress Grave Path Entry"]) - regions["Fortress Grave Path"].connect( + regions["Fortress Grave Path Combat"].connect( + connecting_region=regions["Fortress Grave Path by Grave"]) + + # run past the enemies + regions["Fortress Grave Path by Grave"].connect( + connecting_region=regions["Fortress Grave Path Entry"]) + + regions["Fortress Grave Path by Grave"].connect( connecting_region=regions["Fortress Hero's Grave Region"], rule=lambda state: has_ability(prayer, state, world)) regions["Fortress Hero's Grave Region"].connect( - connecting_region=regions["Fortress Grave Path"]) + connecting_region=regions["Fortress Grave Path by Grave"]) + + regions["Fortress Grave Path by Grave"].connect( + connecting_region=regions["Fortress Grave Path Dusty Entrance Region"], + rule=lambda state: state.has(laurels, player)) + # reverse connection is conditionally made later, depending on whether combat logic is on, and the details of ER regions["Fortress Grave Path Upper"].connect( - connecting_region=regions["Fortress Grave Path"], + connecting_region=regions["Fortress Grave Path Entry"], rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_easy, state, world)) regions["Fortress Arena"].connect( @@ -831,19 +900,19 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Quarry Portal"].connect( connecting_region=regions["Quarry Entry"]) - regions["Quarry Entry"].connect( + quarry_entry_to_main = regions["Quarry Entry"].connect( connecting_region=regions["Quarry"], rule=lambda state: state.has(fire_wand, player) or has_sword(state, player)) regions["Quarry"].connect( connecting_region=regions["Quarry Entry"]) - regions["Quarry Back"].connect( + quarry_back_to_main = regions["Quarry Back"].connect( connecting_region=regions["Quarry"], rule=lambda state: state.has(fire_wand, player) or has_sword(state, player)) regions["Quarry"].connect( connecting_region=regions["Quarry Back"]) - regions["Quarry Monastery Entry"].connect( + monastery_to_quarry_main = regions["Quarry Monastery Entry"].connect( connecting_region=regions["Quarry"], rule=lambda state: state.has(fire_wand, player) or has_sword(state, player)) regions["Quarry"].connect( @@ -869,18 +938,24 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ rule=lambda state: has_ladder("Ladders in Lower Quarry", state, world) or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world)) - # nmg: bring a scav over, then ice grapple through the door, only with ER on to avoid soft lock regions["Even Lower Quarry"].connect( + connecting_region=regions["Even Lower Quarry Isolated Chest"]) + # you grappled down, might as well loot the rest too + lower_quarry_empty_to_combat = regions["Even Lower Quarry Isolated Chest"].connect( + connecting_region=regions["Even Lower Quarry"], + rule=lambda state: has_mask(state, world)) + + regions["Even Lower Quarry Isolated Chest"].connect( connecting_region=regions["Lower Quarry Zig Door"], rule=lambda state: state.has("Activate Quarry Fuse", player) or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world)) - # nmg: use ice grapple to get from the beginning of Quarry to the door without really needing mask only with ER on + # don't need the mask for this either, please don't complain about not needing a mask here, you know what you did regions["Quarry"].connect( - connecting_region=regions["Lower Quarry Zig Door"], + connecting_region=regions["Even Lower Quarry Isolated Chest"], rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_hard, state, world)) - regions["Monastery Front"].connect( + monastery_front_to_back = regions["Monastery Front"].connect( connecting_region=regions["Monastery Back"]) # laurels through the gate, no setup needed regions["Monastery Back"].connect( @@ -897,7 +972,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Rooted Ziggurat Upper Entry"].connect( connecting_region=regions["Rooted Ziggurat Upper Front"]) - regions["Rooted Ziggurat Upper Front"].connect( + zig_upper_front_back = regions["Rooted Ziggurat Upper Front"].connect( connecting_region=regions["Rooted Ziggurat Upper Back"], rule=lambda state: state.has(laurels, player) or has_sword(state, player)) regions["Rooted Ziggurat Upper Back"].connect( @@ -907,13 +982,23 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Rooted Ziggurat Middle Top"].connect( connecting_region=regions["Rooted Ziggurat Middle Bottom"]) + zig_low_entry_to_front = regions["Rooted Ziggurat Lower Entry"].connect( + connecting_region=regions["Rooted Ziggurat Lower Front"]) + regions["Rooted Ziggurat Lower Front"].connect( + connecting_region=regions["Rooted Ziggurat Lower Entry"]) + regions["Rooted Ziggurat Lower Front"].connect( + connecting_region=regions["Rooted Ziggurat Lower Mid Checkpoint"]) + zig_low_mid_to_front = regions["Rooted Ziggurat Lower Mid Checkpoint"].connect( + connecting_region=regions["Rooted Ziggurat Lower Front"]) + + zig_low_mid_to_back = regions["Rooted Ziggurat Lower Mid Checkpoint"].connect( connecting_region=regions["Rooted Ziggurat Lower Back"], rule=lambda state: state.has(laurels, player) or (has_sword(state, player) and has_ability(prayer, state, world))) - # nmg: can ice grapple on the voidlings to the double admin fight, still need to pray at the fuse - regions["Rooted Ziggurat Lower Back"].connect( - connecting_region=regions["Rooted Ziggurat Lower Front"], + # can ice grapple to the voidlings to get to the double admin fight, still need to pray at the fuse + zig_low_back_to_mid = regions["Rooted Ziggurat Lower Back"].connect( + connecting_region=regions["Rooted Ziggurat Lower Mid Checkpoint"], rule=lambda state: (state.has(laurels, player) or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world)) and has_ability(prayer, state, world) @@ -925,8 +1010,10 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Rooted Ziggurat Portal Room Entrance"].connect( connecting_region=regions["Rooted Ziggurat Lower Back"]) - regions["Zig Skip Exit"].connect( - connecting_region=regions["Rooted Ziggurat Lower Front"]) + # zig skip region only gets made if entrance rando and fewer shops are on + if options.entrance_rando and options.fixed_shop: + regions["Zig Skip Exit"].connect( + connecting_region=regions["Rooted Ziggurat Lower Front"]) regions["Rooted Ziggurat Portal"].connect( connecting_region=regions["Rooted Ziggurat Portal Room"]) @@ -952,7 +1039,6 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ or state.has(laurels, player) or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world)) - # a whole lot of stuff to basically say "you need to pray at the overworld fuse" swamp_mid_to_cath = regions["Swamp Mid"].connect( connecting_region=regions["Swamp to Cathedral Main Entrance Region"], rule=lambda state: (has_ability(prayer, state, world) @@ -965,7 +1051,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ "Ladder to Swamp", "Ladders near Weathervane"}, player) or (state.has("Ladder to Ruined Atoll", player) - and state.can_reach_region("Overworld Beach", player)))))) + and state.can_reach_region("Overworld Beach", player))))) + and (not options.combat_logic + or has_combat_reqs("Swamp", state, player))) or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)) if options.ladder_storage >= LadderStorage.option_hard and options.shuffle_ladders: @@ -1017,13 +1105,23 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Swamp Hero's Grave Region"].connect( connecting_region=regions["Back of Swamp"]) - regions["Cathedral"].connect( + cath_entry_to_elev = regions["Cathedral Entry"].connect( connecting_region=regions["Cathedral to Gauntlet"], rule=lambda state: (has_ability(prayer, state, world) or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)) or options.entrance_rando) # elevator is always there in ER regions["Cathedral to Gauntlet"].connect( - connecting_region=regions["Cathedral"]) + connecting_region=regions["Cathedral Entry"]) + + cath_entry_to_main = regions["Cathedral Entry"].connect( + connecting_region=regions["Cathedral Main"]) + regions["Cathedral Main"].connect( + connecting_region=regions["Cathedral Entry"]) + + cath_elev_to_main = regions["Cathedral to Gauntlet"].connect( + connecting_region=regions["Cathedral Main"]) + regions["Cathedral Main"].connect( + connecting_region=regions["Cathedral to Gauntlet"]) regions["Cathedral Gauntlet Checkpoint"].connect( connecting_region=regions["Cathedral Gauntlet"]) @@ -1075,7 +1173,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ connecting_region=regions["Far Shore"]) # Misc - regions["Spirit Arena"].connect( + heir_fight = regions["Spirit Arena"].connect( connecting_region=regions["Spirit Arena Victory"], rule=lambda state: (state.has(gold_hexagon, player, world.options.hexagon_goal.value) if world.options.hexagon_quest else @@ -1219,6 +1317,192 @@ def ls_connect(origin_name: str, portal_sdt: str) -> None: for region in ladder_regions.values(): world.multiworld.regions.append(region) + # for combat logic, easiest to replace or add to existing rules + if world.options.combat_logic >= CombatLogic.option_bosses_only: + set_rule(wg_to_after_gk, + lambda state: state.has(laurels, player) + or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world) + or has_combat_reqs("Garden Knight", state, player)) + # laurels past, or ice grapple it off, or ice grapple to it and fight + set_rule(after_gk_to_wg, + lambda state: state.has(laurels, player) + or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world) + or (has_ice_grapple_logic(False, IceGrappling.option_easy, state, world) + and has_combat_reqs("Garden Knight", state, player))) + + if not world.options.hexagon_quest: + add_rule(heir_fight, + lambda state: has_combat_reqs("The Heir", state, player)) + + if world.options.combat_logic == CombatLogic.option_on: + # these are redundant with combat logic off + regions["Fortress Grave Path Entry"].connect( + connecting_region=regions["Fortress Grave Path Dusty Entrance Region"], + rule=lambda state: state.has(laurels, player)) + + regions["Rooted Ziggurat Lower Entry"].connect( + connecting_region=regions["Rooted Ziggurat Lower Mid Checkpoint"], + rule=lambda state: state.has(laurels, player)) + regions["Rooted Ziggurat Lower Mid Checkpoint"].connect( + connecting_region=regions["Rooted Ziggurat Lower Entry"], + rule=lambda state: state.has(laurels, player)) + + add_rule(ow_to_town_portal, + lambda state: has_combat_reqs("Before Well", state, player)) + # need to fight through the rudelings and turret, or just laurels from near the windmill + set_rule(ow_to_well_entry, + lambda state: state.has(laurels, player) + or has_combat_reqs("East Forest", state, player)) + set_rule(ow_tunnel_beach, + lambda state: has_combat_reqs("East Forest", state, player)) + + add_rule(atoll_statue, + lambda state: has_combat_reqs("Ruined Atoll", state, player)) + set_rule(frogs_front_to_main, + lambda state: has_combat_reqs("Frog's Domain", state, player)) + + set_rule(btw_front_main, + lambda state: state.has(laurels, player) or has_combat_reqs("Beneath the Well", state, player)) + set_rule(btw_back_main, + lambda state: has_ladder("Ladders in Well", state, world) + and (state.has(laurels, player) or has_combat_reqs("Beneath the Well", state, player))) + set_rule(well_boss_to_dt, + lambda state: has_combat_reqs("Beneath the Well", state, player) + or laurels_zip(state, world)) + + add_rule(dt_entry_to_upper, + lambda state: has_combat_reqs("Dark Tomb", state, player)) + add_rule(dt_exit_to_main, + lambda state: has_combat_reqs("Dark Tomb", state, player)) + + set_rule(wg_before_to_after_terry, + lambda state: state.has_any({laurels, ice_dagger}, player) + or has_combat_reqs("West Garden", state, player)) + set_rule(wg_after_to_before_terry, + lambda state: state.has_any({laurels, ice_dagger}, player) + or has_combat_reqs("West Garden", state, player)) + # laurels through, probably to the checkpoint, or just fight + set_rule(wg_checkpoint_to_after_terry, + lambda state: state.has(laurels, player) or has_combat_reqs("West Garden", state, player)) + set_rule(wg_checkpoint_to_before_boss, + lambda state: has_combat_reqs("West Garden", state, player)) + + add_rule(btv_front_to_main, + lambda state: has_combat_reqs("Beneath the Vault", state, player)) + add_rule(btv_back_to_main, + lambda state: has_combat_reqs("Beneath the Vault", state, player)) + + add_rule(fort_upper_lower, + lambda state: state.has(ice_dagger, player) + or has_combat_reqs("Eastern Vault Fortress", state, player)) + set_rule(fort_grave_entry_to_combat, + lambda state: has_combat_reqs("Eastern Vault Fortress", state, player)) + + set_rule(quarry_entry_to_main, + lambda state: has_combat_reqs("Quarry", state, player)) + set_rule(quarry_back_to_main, + lambda state: has_combat_reqs("Quarry", state, player)) + set_rule(monastery_to_quarry_main, + lambda state: has_combat_reqs("Quarry", state, player)) + set_rule(monastery_front_to_back, + lambda state: has_combat_reqs("Quarry", state, player)) + set_rule(lower_quarry_empty_to_combat, + lambda state: has_combat_reqs("Quarry", state, player)) + + set_rule(zig_upper_front_back, + lambda state: state.has(laurels, player) + or has_combat_reqs("Rooted Ziggurat", state, player)) + set_rule(zig_low_entry_to_front, + lambda state: has_combat_reqs("Rooted Ziggurat", state, player)) + set_rule(zig_low_mid_to_front, + lambda state: has_combat_reqs("Rooted Ziggurat", state, player)) + set_rule(zig_low_mid_to_back, + lambda state: state.has(laurels, player) + or (has_ability(prayer, state, world) and has_combat_reqs("Rooted Ziggurat", state, player))) + set_rule(zig_low_back_to_mid, + lambda state: (state.has(laurels, player) + or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world)) + and has_ability(prayer, state, world) + and has_combat_reqs("Rooted Ziggurat", state, player)) + + # only activating the fuse requires combat logic + set_rule(cath_entry_to_elev, + lambda state: options.entrance_rando + or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world) + or (has_ability(prayer, state, world) and has_combat_reqs("Cathedral", state, player))) + + set_rule(cath_entry_to_main, + lambda state: has_combat_reqs("Cathedral", state, player)) + set_rule(cath_elev_to_main, + lambda state: has_combat_reqs("Cathedral", state, player)) + + # for spots where you can go into and come out of an entrance to reset enemy aggro + if world.options.entrance_rando: + # for the chest outside of magic dagger house + dagger_entry_paired_name, dagger_entry_paired_region = ( + get_paired_portal("Archipelagos Redux, archipelagos_house_")) + try: + dagger_entry_paired_entrance = world.get_entrance(dagger_entry_paired_name) + except KeyError: + # there is no paired entrance, so you must fight or dash past, which is done in the finally + pass + else: + set_rule(wg_checkpoint_to_dagger, + lambda state: dagger_entry_paired_entrance.can_reach(state)) + world.multiworld.register_indirect_condition(region=regions["West Garden at Dagger House"], + entrance=dagger_entry_paired_entrance) + finally: + add_rule(wg_checkpoint_to_dagger, + lambda state: state.has(laurels, player) or has_combat_reqs("West Garden", state, player), + combine="or") + + # zip past enemies in fortress grave path to enter the dusty entrance, then come back out + fort_dusty_paired_name, fort_dusty_paired_region = get_paired_portal("Fortress Reliquary, Dusty_") + try: + fort_dusty_paired_entrance = world.get_entrance(fort_dusty_paired_name) + except KeyError: + # there is no paired entrance, so you can't run past to deaggro + # the path to dusty can be done via combat, so no need to do anything here + pass + else: + # there is a paired entrance, so you can use that to deaggro enemies + regions["Fortress Grave Path Dusty Entrance Region"].connect( + connecting_region=regions["Fortress Grave Path by Grave"], + rule=lambda state: state.has(laurels, player) and fort_dusty_paired_entrance.can_reach(state)) + world.multiworld.register_indirect_condition(region=regions["Fortress Grave Path by Grave"], + entrance=fort_dusty_paired_entrance) + + # for activating the ladder switch to get from fortress east upper to lower + fort_east_upper_right_paired_name, fort_east_upper_right_paired_region = ( + get_paired_portal("Fortress East, Fortress Courtyard_")) + try: + fort_east_upper_right_paired_entrance = ( + world.get_entrance(fort_east_upper_right_paired_name)) + except KeyError: + # no paired entrance, so you must fight, which is done in the finally + pass + else: + set_rule(fort_east_upper_lower, + lambda state: fort_east_upper_right_paired_entrance.can_reach(state)) + world.multiworld.register_indirect_condition(region=regions["Fortress East Shortcut Lower"], + entrance=fort_east_upper_right_paired_entrance) + finally: + add_rule(fort_east_upper_lower, + lambda state: has_combat_reqs("Eastern Vault Fortress", state, player) + or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world), + combine="or") + + else: + # if combat logic is on and ER is off, we can make this entrance freely + regions["Fortress Grave Path Dusty Entrance Region"].connect( + connecting_region=regions["Fortress Grave Path by Grave"], + rule=lambda state: state.has(laurels, player)) + else: + # if combat logic is off, we can make this entrance freely + regions["Fortress Grave Path Dusty Entrance Region"].connect( + connecting_region=regions["Fortress Grave Path by Grave"], + rule=lambda state: state.has(laurels, player)) + def set_er_location_rules(world: "TunicWorld") -> None: player = world.player @@ -1315,6 +1599,11 @@ def set_er_location_rules(world: "TunicWorld") -> None: set_rule(world.get_location("East Forest - Ice Rod Grapple Chest"), lambda state: ( state.has_all({grapple, ice_dagger, fire_wand}, player) and has_ability(icebolt, state, world))) + # Dark Tomb + # added to make combat logic smoother + set_rule(world.get_location("Dark Tomb - 2nd Laser Room"), + lambda state: has_lantern(state, world)) + # West Garden set_rule(world.get_location("West Garden - [North] Across From Page Pickup"), lambda state: state.has(laurels, player)) @@ -1348,11 +1637,11 @@ def set_er_location_rules(world: "TunicWorld") -> None: # Library Lab set_rule(world.get_location("Library Lab - Page 1"), - lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player)) + lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player)) set_rule(world.get_location("Library Lab - Page 2"), - lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player)) + lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player)) set_rule(world.get_location("Library Lab - Page 3"), - lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player)) + lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player)) # Eastern Vault Fortress set_rule(world.get_location("Fortress Arena - Hexagon Red"), @@ -1361,11 +1650,11 @@ def set_er_location_rules(world: "TunicWorld") -> None: # gun isn't included since it can only break one leaf pile at a time, and we don't check how much mana you have # but really, I expect the player to just throw a bomb at them if they don't have melee set_rule(world.get_location("Fortress Leaf Piles - Secret Chest"), - lambda state: has_stick(state, player) or state.has(ice_dagger, player)) + lambda state: has_melee(state, player) or state.has(ice_dagger, player)) # Beneath the Vault set_rule(world.get_location("Beneath the Fortress - Bridge"), - lambda state: has_stick(state, player) or state.has_any({laurels, fire_wand}, player)) + lambda state: has_melee(state, player) or state.has_any({laurels, fire_wand}, player)) # Quarry set_rule(world.get_location("Quarry - [Central] Above Ladder Dash Chest"), @@ -1421,9 +1710,9 @@ def set_er_location_rules(world: "TunicWorld") -> None: # Events set_rule(world.get_location("Eastern Bell"), - lambda state: (has_stick(state, player) or state.has(fire_wand, player))) + lambda state: (has_melee(state, player) or state.has(fire_wand, player))) set_rule(world.get_location("Western Bell"), - lambda state: (has_stick(state, player) or state.has(fire_wand, player))) + lambda state: (has_melee(state, player) or state.has(fire_wand, player))) set_rule(world.get_location("Furnace Fuse"), lambda state: has_ability(prayer, state, world)) set_rule(world.get_location("South and West Fortress Exterior Fuses"), @@ -1470,3 +1759,129 @@ def set_er_location_rules(world: "TunicWorld") -> None: lambda state: has_sword(state, player)) set_rule(world.get_location("Shop - Coin 2"), lambda state: has_sword(state, player)) + + def combat_logic_to_loc(loc_name: str, combat_req_area: str, set_instead: bool = False, + dagger: bool = False, laurel: bool = False) -> None: + # dagger means you can use magic dagger instead of combat for that check + # laurel means you can dodge the enemies freely with the laurels + if set_instead: + set_rule(world.get_location(loc_name), + lambda state: has_combat_reqs(combat_req_area, state, player) + or (dagger and state.has(ice_dagger, player)) + or (laurel and state.has(laurels, player))) + else: + add_rule(world.get_location(loc_name), + lambda state: has_combat_reqs(combat_req_area, state, player) + or (dagger and state.has(ice_dagger, player)) + or (laurel and state.has(laurels, player))) + + if world.options.combat_logic >= CombatLogic.option_bosses_only: + # garden knight is in the regions part above + combat_logic_to_loc("Fortress Arena - Siege Engine/Vault Key Pickup", "Siege Engine", set_instead=True) + combat_logic_to_loc("Librarian - Hexagon Green", "The Librarian", set_instead=True) + set_rule(world.get_location("Librarian - Hexagon Green"), + rule=lambda state: has_combat_reqs("The Librarian", state, player) + and has_ladder("Ladders in Library", state, world)) + combat_logic_to_loc("Rooted Ziggurat Lower - Hexagon Blue", "Boss Scavenger", set_instead=True) + if world.options.ice_grappling >= IceGrappling.option_medium: + add_rule(world.get_location("Rooted Ziggurat Lower - Hexagon Blue"), + lambda state: has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)) + combat_logic_to_loc("Cathedral Gauntlet - Gauntlet Reward", "Gauntlet", set_instead=True) + + if world.options.combat_logic == CombatLogic.option_on: + combat_logic_to_loc("Overworld - [Northeast] Flowers Holy Cross", "Garden Knight") + combat_logic_to_loc("Overworld - [Northwest] Chest Near Quarry Gate", "Before Well", dagger=True) + combat_logic_to_loc("Overworld - [Northeast] Chest Above Patrol Cave", "Garden Knight", dagger=True) + combat_logic_to_loc("Overworld - [Southwest] West Beach Guarded By Turret", "Overworld", dagger=True) + combat_logic_to_loc("Overworld - [Southwest] West Beach Guarded By Turret 2", "Overworld") + combat_logic_to_loc("Overworld - [Southwest] Bombable Wall Near Fountain", "East Forest", dagger=True) + combat_logic_to_loc("Overworld - [Southwest] Fountain Holy Cross", "East Forest", dagger=True) + combat_logic_to_loc("Overworld - [Southwest] South Chest Near Guard", "East Forest", dagger=True) + combat_logic_to_loc("Overworld - [Southwest] Tunnel Guarded By Turret", "East Forest", dagger=True) + combat_logic_to_loc("Overworld - [Northwest] Chest Near Turret", "Before Well") + + add_rule(world.get_location("Hourglass Cave - Hourglass Chest"), + lambda state: has_sword(state, player) and (state.has("Shield", player) + # kill the turrets through the wall with a longer sword + or state.has("Sword Upgrade", player, 3))) + add_rule(world.get_location("Hourglass Cave - Holy Cross Chest"), + lambda state: has_sword(state, player) and (state.has("Shield", player) + or state.has("Sword Upgrade", player, 3))) + + # the first spider chest they literally do not attack you until you open the chest + # the second one, you can still just walk past them, but I guess /something/ would be wanted + combat_logic_to_loc("East Forest - Beneath Spider Chest", "East Forest", dagger=True, laurel=True) + combat_logic_to_loc("East Forest - Golden Obelisk Holy Cross", "East Forest", dagger=True) + combat_logic_to_loc("East Forest - Dancing Fox Spirit Holy Cross", "East Forest", dagger=True, laurel=True) + combat_logic_to_loc("East Forest - From Guardhouse 1 Chest", "East Forest", dagger=True, laurel=True) + combat_logic_to_loc("East Forest - Above Save Point", "East Forest", dagger=True) + combat_logic_to_loc("East Forest - Above Save Point Obscured", "East Forest", dagger=True) + combat_logic_to_loc("Forest Grave Path - Above Gate", "East Forest", dagger=True, laurel=True) + combat_logic_to_loc("Forest Grave Path - Obscured Chest", "East Forest", dagger=True, laurel=True) + + # most of beneath the well is covered by the region access rule + combat_logic_to_loc("Beneath the Well - [Entryway] Chest", "Beneath the Well") + combat_logic_to_loc("Beneath the Well - [Entryway] Obscured Behind Waterfall", "Beneath the Well") + combat_logic_to_loc("Beneath the Well - [Back Corridor] Left Secret", "Beneath the Well") + combat_logic_to_loc("Beneath the Well - [Side Room] Chest By Phrends", "Overworld") + + # laurels past the enemies, then use the wand or gun to take care of the fairies that chased you + add_rule(world.get_location("West Garden - [West Lowlands] Tree Holy Cross Chest"), + lambda state: state.has_any({fire_wand, "Gun"}, player)) + combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Faeries", "West Garden") + combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Save Point", "West Garden") + combat_logic_to_loc("West Garden - [West Highlands] Upper Left Walkway", "West Garden") + + # with combat logic on, I presume the player will want to be able to see to avoid the spiders + set_rule(world.get_location("Beneath the Fortress - Bridge"), + lambda state: has_lantern(state, world) + and (state.has_any({laurels, fire_wand, "Gun"}, player) or has_melee(state, player))) + + combat_logic_to_loc("Eastern Vault Fortress - [West Wing] Candles Holy Cross", "Eastern Vault Fortress", + dagger=True) + + # could just do the last two, but this outputs better in the spoiler log + # dagger is maybe viable here, but it's sketchy -- activate ladder switch, save to reset enemies, climb up + combat_logic_to_loc("Upper and Central Fortress Exterior Fuses", "Eastern Vault Fortress") + combat_logic_to_loc("Beneath the Vault Fuse", "Beneath the Vault") + combat_logic_to_loc("Eastern Vault West Fuses", "Eastern Vault Fortress") + + # if you come in from the left, you only need to fight small crabs + add_rule(world.get_location("Ruined Atoll - [South] Near Birds"), + lambda state: has_melee(state, player) or state.has_any({laurels, "Gun"}, player)) + + # can get this one without fighting if you have laurels + add_rule(world.get_location("Frog's Domain - Above Vault"), + lambda state: state.has(laurels, player) or has_combat_reqs("Frog's Domain", state, player)) + + # with wand, you can get this chest. Non-ER, you need laurels to continue down. ER, you can just torch + set_rule(world.get_location("Rooted Ziggurat Upper - Near Bridge Switch"), + lambda state: (state.has(fire_wand, player) + and (state.has(laurels, player) or world.options.entrance_rando)) + or has_combat_reqs("Rooted Ziggurat", state, player)) + set_rule(world.get_location("Rooted Ziggurat Lower - After Guarded Fuse"), + lambda state: has_ability(prayer, state, world) + and has_combat_reqs("Rooted Ziggurat", state, player)) + + # replace the sword rule with this one + combat_logic_to_loc("Swamp - [South Graveyard] 4 Orange Skulls", "Swamp", set_instead=True) + combat_logic_to_loc("Swamp - [South Graveyard] Guarded By Big Skeleton", "Swamp", dagger=True) + # don't really agree with this one but eh + combat_logic_to_loc("Swamp - [South Graveyard] Above Big Skeleton", "Swamp", dagger=True, laurel=True) + # the tentacles deal with everything else reasonably, and you can hide on the island, so no rule for it + add_rule(world.get_location("Swamp - [South Graveyard] Obscured Beneath Telescope"), + lambda state: state.has(laurels, player) # can dash from swamp mid to here and grab it + or has_combat_reqs("Swamp", state, player)) + add_rule(world.get_location("Swamp - [Central] South Secret Passage"), + lambda state: state.has(laurels, player) # can dash from swamp front to here and grab it + or has_combat_reqs("Swamp", state, player)) + combat_logic_to_loc("Swamp - [South Graveyard] Upper Walkway On Pedestal", "Swamp") + combat_logic_to_loc("Swamp - [Central] Beneath Memorial", "Swamp") + combat_logic_to_loc("Swamp - [Central] Near Ramps Up", "Swamp") + combat_logic_to_loc("Swamp - [Upper Graveyard] Near Telescope", "Swamp") + combat_logic_to_loc("Swamp - [Upper Graveyard] Near Shield Fleemers", "Swamp") + combat_logic_to_loc("Swamp - [Upper Graveyard] Obscured Behind Hill", "Swamp") + + # zip through the rubble to sneakily grab this chest, or just fight to it + add_rule(world.get_location("Cathedral - [1F] Near Spikes"), + lambda state: laurels_zip(state, world) or has_combat_reqs("Cathedral", state, player)) diff --git a/worlds/tunic/er_scripts.py b/worlds/tunic/er_scripts.py index 05f6177aa57d..aa5833b4db36 100644 --- a/worlds/tunic/er_scripts.py +++ b/worlds/tunic/er_scripts.py @@ -22,10 +22,19 @@ class TunicERLocation(Location): def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]: regions: Dict[str, Region] = {} - for region_name, region_data in world.er_regions.items(): - regions[region_name] = Region(region_name, world.player, world.multiworld) if world.options.entrance_rando: + for region_name, region_data in world.er_regions.items(): + # if fewer shops is off, zig skip is not made + if region_name == "Zig Skip Exit": + # need to check if there's a seed group for this first + if world.options.entrance_rando.value not in EntranceRando.options.values(): + if not world.seed_groups[world.options.entrance_rando.value]["fixed_shop"]: + continue + elif not world.options.fixed_shop: + continue + regions[region_name] = Region(region_name, world.player, world.multiworld) + portal_pairs = pair_portals(world, regions) # output the entrances to the spoiler log here for convenience @@ -33,16 +42,21 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]: for portal1, portal2 in sorted_portal_pairs.items(): world.multiworld.spoiler.set_entrance(portal1, portal2, "both", world.player) else: + for region_name, region_data in world.er_regions.items(): + # filter out regions that are inaccessible in non-er + if region_name not in ["Zig Skip Exit", "Purgatory"]: + regions[region_name] = Region(region_name, world.player, world.multiworld) + portal_pairs = vanilla_portals(world, regions) + create_randomized_entrances(portal_pairs, regions) + set_er_region_rules(world, regions, portal_pairs) for location_name, location_id in world.location_name_to_id.items(): region = regions[location_table[location_name].er_region] location = TunicERLocation(world.player, location_name, location_id, region) region.locations.append(location) - - create_randomized_entrances(portal_pairs, regions) for region in regions.values(): world.multiworld.regions.append(region) @@ -70,7 +84,7 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]: "Quarry Connector Fuse": "Quarry Connector", "Quarry Fuse": "Quarry Entry", "Ziggurat Fuse": "Rooted Ziggurat Lower Back", - "West Garden Fuse": "West Garden", + "West Garden Fuse": "West Garden South Checkpoint", "Library Fuse": "Library Lab", "Place Questagons": "Sealed Temple", } @@ -108,7 +122,8 @@ def create_shop_region(world: "TunicWorld", regions: Dict[str, Region]) -> None: def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal, Portal]: portal_pairs: Dict[Portal, Portal] = {} # we don't want the zig skip exit for vanilla portals, since it shouldn't be considered for logic here - portal_map = [portal for portal in portal_mapping if portal.name != "Ziggurat Lower Falling Entrance"] + portal_map = [portal for portal in portal_mapping if portal.name not in + ["Ziggurat Lower Falling Entrance", "Purgatory Bottom Exit", "Purgatory Top Exit"]] while portal_map: portal1 = portal_map[0] @@ -121,9 +136,6 @@ def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Por destination="Previous Region", tag="_") create_shop_region(world, regions) - elif portal2_sdt == "Purgatory, Purgatory_bottom": - portal2_sdt = "Purgatory, Purgatory_top" - for portal in portal_map: if portal.scene_destination() == portal2_sdt: portal2 = portal @@ -414,6 +426,7 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal cr.add(portal.region) if "Secret Gathering Place" not in update_reachable_regions(cr, traversal_reqs, has_laurels, logic_tricks): continue + # if not waterfall_plando, then we just want to pair secret gathering place now elif portal.region != "Secret Gathering Place": continue portal2 = portal diff --git a/worlds/tunic/items.py b/worlds/tunic/items.py index b6ce5d8995a8..f30c1d5d248a 100644 --- a/worlds/tunic/items.py +++ b/worlds/tunic/items.py @@ -1,5 +1,5 @@ from itertools import groupby -from typing import Dict, List, Set, NamedTuple +from typing import Dict, List, Set, NamedTuple, Optional from BaseClasses import ItemClassification as IC @@ -8,6 +8,8 @@ class TunicItemData(NamedTuple): quantity_in_item_pool: int item_id_offset: int item_group: str = "" + # classification if combat logic is on + combat_ic: Optional[IC] = None item_base_id = 509342400 @@ -27,7 +29,7 @@ class TunicItemData(NamedTuple): "Lure x2": TunicItemData(IC.filler, 1, 11, "Consumables"), "Pepper x2": TunicItemData(IC.filler, 4, 12, "Consumables"), "Ivy x3": TunicItemData(IC.filler, 2, 13, "Consumables"), - "Effigy": TunicItemData(IC.useful, 12, 14, "Money"), + "Effigy": TunicItemData(IC.useful, 12, 14, "Money", combat_ic=IC.progression), "HP Berry": TunicItemData(IC.filler, 2, 15, "Consumables"), "HP Berry x2": TunicItemData(IC.filler, 4, 16, "Consumables"), "HP Berry x3": TunicItemData(IC.filler, 2, 17, "Consumables"), @@ -44,32 +46,32 @@ class TunicItemData(NamedTuple): "Hero's Laurels": TunicItemData(IC.progression | IC.useful, 1, 28), "Lantern": TunicItemData(IC.progression, 1, 29), "Gun": TunicItemData(IC.progression | IC.useful, 1, 30, "Weapons"), - "Shield": TunicItemData(IC.useful, 1, 31), + "Shield": TunicItemData(IC.useful, 1, 31, combat_ic=IC.progression | IC.useful), "Dath Stone": TunicItemData(IC.useful, 1, 32), "Hourglass": TunicItemData(IC.useful, 1, 33), "Old House Key": TunicItemData(IC.progression, 1, 34, "Keys"), "Key": TunicItemData(IC.progression, 2, 35, "Keys"), "Fortress Vault Key": TunicItemData(IC.progression, 1, 36, "Keys"), - "Flask Shard": TunicItemData(IC.useful, 12, 37), - "Potion Flask": TunicItemData(IC.useful, 5, 38, "Flask"), + "Flask Shard": TunicItemData(IC.useful, 12, 37, combat_ic=IC.progression), + "Potion Flask": TunicItemData(IC.useful, 5, 38, "Flask", combat_ic=IC.progression), "Golden Coin": TunicItemData(IC.progression, 17, 39), "Card Slot": TunicItemData(IC.useful, 4, 40), "Red Questagon": TunicItemData(IC.progression_skip_balancing, 1, 41, "Hexagons"), "Green Questagon": TunicItemData(IC.progression_skip_balancing, 1, 42, "Hexagons"), "Blue Questagon": TunicItemData(IC.progression_skip_balancing, 1, 43, "Hexagons"), "Gold Questagon": TunicItemData(IC.progression_skip_balancing, 0, 44, "Hexagons"), - "ATT Offering": TunicItemData(IC.useful, 4, 45, "Offerings"), - "DEF Offering": TunicItemData(IC.useful, 4, 46, "Offerings"), - "Potion Offering": TunicItemData(IC.useful, 3, 47, "Offerings"), - "HP Offering": TunicItemData(IC.useful, 6, 48, "Offerings"), - "MP Offering": TunicItemData(IC.useful, 3, 49, "Offerings"), - "SP Offering": TunicItemData(IC.useful, 2, 50, "Offerings"), - "Hero Relic - ATT": TunicItemData(IC.progression_skip_balancing, 1, 51, "Hero Relics"), - "Hero Relic - DEF": TunicItemData(IC.progression_skip_balancing, 1, 52, "Hero Relics"), - "Hero Relic - HP": TunicItemData(IC.progression_skip_balancing, 1, 53, "Hero Relics"), - "Hero Relic - MP": TunicItemData(IC.progression_skip_balancing, 1, 54, "Hero Relics"), - "Hero Relic - POTION": TunicItemData(IC.progression_skip_balancing, 1, 55, "Hero Relics"), - "Hero Relic - SP": TunicItemData(IC.progression_skip_balancing, 1, 56, "Hero Relics"), + "ATT Offering": TunicItemData(IC.useful, 4, 45, "Offerings", combat_ic=IC.progression), + "DEF Offering": TunicItemData(IC.useful, 4, 46, "Offerings", combat_ic=IC.progression), + "Potion Offering": TunicItemData(IC.useful, 3, 47, "Offerings", combat_ic=IC.progression), + "HP Offering": TunicItemData(IC.useful, 6, 48, "Offerings", combat_ic=IC.progression), + "MP Offering": TunicItemData(IC.useful, 3, 49, "Offerings", combat_ic=IC.progression), + "SP Offering": TunicItemData(IC.useful, 2, 50, "Offerings", combat_ic=IC.progression), + "Hero Relic - ATT": TunicItemData(IC.progression_skip_balancing, 1, 51, "Hero Relics", combat_ic=IC.progression), + "Hero Relic - DEF": TunicItemData(IC.progression_skip_balancing, 1, 52, "Hero Relics", combat_ic=IC.progression), + "Hero Relic - HP": TunicItemData(IC.progression_skip_balancing, 1, 53, "Hero Relics", combat_ic=IC.progression), + "Hero Relic - MP": TunicItemData(IC.progression_skip_balancing, 1, 54, "Hero Relics", combat_ic=IC.progression), + "Hero Relic - POTION": TunicItemData(IC.progression_skip_balancing, 1, 55, "Hero Relics", combat_ic=IC.progression), + "Hero Relic - SP": TunicItemData(IC.progression_skip_balancing, 1, 56, "Hero Relics", combat_ic=IC.progression), "Orange Peril Ring": TunicItemData(IC.useful, 1, 57, "Cards"), "Tincture": TunicItemData(IC.useful, 1, 58, "Cards"), "Scavenger Mask": TunicItemData(IC.progression, 1, 59, "Cards"), @@ -86,18 +88,18 @@ class TunicItemData(NamedTuple): "Louder Echo": TunicItemData(IC.useful, 1, 70, "Cards"), "Aura's Gem": TunicItemData(IC.useful, 1, 71, "Cards"), "Bone Card": TunicItemData(IC.useful, 1, 72, "Cards"), - "Mr Mayor": TunicItemData(IC.useful, 1, 73, "Golden Treasures"), - "Secret Legend": TunicItemData(IC.useful, 1, 74, "Golden Treasures"), - "Sacred Geometry": TunicItemData(IC.useful, 1, 75, "Golden Treasures"), - "Vintage": TunicItemData(IC.useful, 1, 76, "Golden Treasures"), - "Just Some Pals": TunicItemData(IC.useful, 1, 77, "Golden Treasures"), - "Regal Weasel": TunicItemData(IC.useful, 1, 78, "Golden Treasures"), - "Spring Falls": TunicItemData(IC.useful, 1, 79, "Golden Treasures"), - "Power Up": TunicItemData(IC.useful, 1, 80, "Golden Treasures"), - "Back To Work": TunicItemData(IC.useful, 1, 81, "Golden Treasures"), - "Phonomath": TunicItemData(IC.useful, 1, 82, "Golden Treasures"), - "Dusty": TunicItemData(IC.useful, 1, 83, "Golden Treasures"), - "Forever Friend": TunicItemData(IC.useful, 1, 84, "Golden Treasures"), + "Mr Mayor": TunicItemData(IC.useful, 1, 73, "Golden Treasures", combat_ic=IC.progression), + "Secret Legend": TunicItemData(IC.useful, 1, 74, "Golden Treasures", combat_ic=IC.progression), + "Sacred Geometry": TunicItemData(IC.useful, 1, 75, "Golden Treasures", combat_ic=IC.progression), + "Vintage": TunicItemData(IC.useful, 1, 76, "Golden Treasures", combat_ic=IC.progression), + "Just Some Pals": TunicItemData(IC.useful, 1, 77, "Golden Treasures", combat_ic=IC.progression), + "Regal Weasel": TunicItemData(IC.useful, 1, 78, "Golden Treasures", combat_ic=IC.progression), + "Spring Falls": TunicItemData(IC.useful, 1, 79, "Golden Treasures", combat_ic=IC.progression), + "Power Up": TunicItemData(IC.useful, 1, 80, "Golden Treasures", combat_ic=IC.progression), + "Back To Work": TunicItemData(IC.useful, 1, 81, "Golden Treasures", combat_ic=IC.progression), + "Phonomath": TunicItemData(IC.useful, 1, 82, "Golden Treasures", combat_ic=IC.progression), + "Dusty": TunicItemData(IC.useful, 1, 83, "Golden Treasures", combat_ic=IC.progression), + "Forever Friend": TunicItemData(IC.useful, 1, 84, "Golden Treasures", combat_ic=IC.progression), "Fool Trap": TunicItemData(IC.trap, 0, 85), "Money x1": TunicItemData(IC.filler, 3, 86, "Money"), "Money x10": TunicItemData(IC.filler, 1, 87, "Money"), @@ -112,9 +114,9 @@ class TunicItemData(NamedTuple): "Money x50": TunicItemData(IC.filler, 7, 96, "Money"), "Money x64": TunicItemData(IC.filler, 1, 97, "Money"), "Money x100": TunicItemData(IC.filler, 5, 98, "Money"), - "Money x128": TunicItemData(IC.useful, 3, 99, "Money"), - "Money x200": TunicItemData(IC.useful, 1, 100, "Money"), - "Money x255": TunicItemData(IC.useful, 1, 101, "Money"), + "Money x128": TunicItemData(IC.useful, 3, 99, "Money", combat_ic=IC.progression), + "Money x200": TunicItemData(IC.useful, 1, 100, "Money", combat_ic=IC.progression), + "Money x255": TunicItemData(IC.useful, 1, 101, "Money", combat_ic=IC.progression), "Pages 0-1": TunicItemData(IC.useful, 1, 102, "Pages"), "Pages 2-3": TunicItemData(IC.useful, 1, 103, "Pages"), "Pages 4-5": TunicItemData(IC.useful, 1, 104, "Pages"), @@ -206,6 +208,10 @@ class TunicItemData(NamedTuple): "Gold Questagon", ] +combat_items: List[str] = [name for name, data in item_table.items() + if data.combat_ic and IC.progression in data.combat_ic] +combat_items.extend(["Stick", "Sword", "Sword Upgrade", "Magic Wand", "Hero's Laurels"]) + item_name_to_id: Dict[str, int] = {name: item_base_id + data.item_id_offset for name, data in item_table.items()} filler_items: List[str] = [name for name, data in item_table.items() if data.classification == IC.filler] diff --git a/worlds/tunic/ladder_storage_data.py b/worlds/tunic/ladder_storage_data.py index c6dda42bca79..f2d4b94406ac 100644 --- a/worlds/tunic/ladder_storage_data.py +++ b/worlds/tunic/ladder_storage_data.py @@ -78,9 +78,11 @@ class LadderInfo(NamedTuple): # West Garden # exit after Garden Knight - LadderInfo("West Garden", "Archipelagos Redux, Overworld Redux_upper"), + LadderInfo("West Garden before Boss", "Archipelagos Redux, Overworld Redux_upper"), # West Garden laurels exit - LadderInfo("West Garden", "Archipelagos Redux, Overworld Redux_lowest"), + LadderInfo("West Garden after Terry", "Archipelagos Redux, Overworld Redux_lowest"), + # Magic dagger house, only relevant with combat logic on + LadderInfo("West Garden after Terry", "Archipelagos Redux, archipelagos_house_"), # Atoll, use the little ladder you fix at the beginning LadderInfo("Ruined Atoll", "Atoll Redux, Overworld Redux_lower"), @@ -159,7 +161,8 @@ class LadderInfo(NamedTuple): LadderInfo("Quarry Back", "Quarry Redux, Monastery_back"), LadderInfo("Rooted Ziggurat Lower Back", "ziggurat2020_3, ziggurat2020_2_"), - LadderInfo("Rooted Ziggurat Lower Back", "Rooted Ziggurat Lower Front", dest_is_region=True), + LadderInfo("Rooted Ziggurat Lower Back", "Rooted Ziggurat Lower Entry", dest_is_region=True), + LadderInfo("Rooted Ziggurat Lower Back", "Rooted Ziggurat Lower Mid Checkpoint", dest_is_region=True), # Swamp to Overworld upper LadderInfo("Swamp Mid", "Swamp Redux 2, Overworld Redux_wall", "Ladders in Swamp"), @@ -172,9 +175,9 @@ class LadderInfo(NamedTuple): LadderInfo("Beneath the Well Front", "Sewer, Overworld Redux_west_aqueduct", "Ladders in Well"), LadderInfo("Beneath the Well Front", "Beneath the Well Back", "Ladders in Well", dest_is_region=True), # go through the hexagon engraving above the vault door - LadderInfo("Frog's Domain", "frog cave main, Frog Stairs_Exit", "Ladders to Frog's Domain"), + LadderInfo("Frog's Domain Front", "frog cave main, Frog Stairs_Exit", "Ladders to Frog's Domain"), # the turret at the end here is not affected by enemy rando - LadderInfo("Frog's Domain", "Frog's Domain Back", "Ladders to Frog's Domain", dest_is_region=True), + LadderInfo("Frog's Domain Front", "Frog's Domain Back", "Ladders to Frog's Domain", dest_is_region=True), # todo: see if we can use that new laurels strat here # LadderInfo("Rooted Ziggurat Lower Back", "ziggurat2020_3, ziggurat2020_FTRoom_"), # go behind the cathedral to reach the door, pretty easily doable diff --git a/worlds/tunic/locations.py b/worlds/tunic/locations.py index 442e0c01446d..5ea309fb19d7 100644 --- a/worlds/tunic/locations.py +++ b/worlds/tunic/locations.py @@ -25,17 +25,17 @@ class TunicLocationData(NamedTuple): "Beneath the Well - [Side Room] Chest By Phrends": TunicLocationData("Beneath the Well", "Beneath the Well Back"), "Beneath the Well - [Second Room] Page": TunicLocationData("Beneath the Well", "Beneath the Well Main"), "Dark Tomb Checkpoint - [Passage To Dark Tomb] Page Pickup": TunicLocationData("Overworld", "Dark Tomb Checkpoint"), - "Cathedral - [1F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral"), - "Cathedral - [1F] Near Spikes": TunicLocationData("Cathedral", "Cathedral"), - "Cathedral - [2F] Bird Room": TunicLocationData("Cathedral", "Cathedral"), - "Cathedral - [2F] Entryway Upper Walkway": TunicLocationData("Cathedral", "Cathedral"), - "Cathedral - [1F] Library": TunicLocationData("Cathedral", "Cathedral"), - "Cathedral - [2F] Library": TunicLocationData("Cathedral", "Cathedral"), - "Cathedral - [2F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral"), - "Cathedral - [2F] Bird Room Secret": TunicLocationData("Cathedral", "Cathedral"), - "Cathedral - [1F] Library Secret": TunicLocationData("Cathedral", "Cathedral"), + "Cathedral - [1F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral Main"), + "Cathedral - [1F] Near Spikes": TunicLocationData("Cathedral", "Cathedral Entry"), # entry because special rules + "Cathedral - [2F] Bird Room": TunicLocationData("Cathedral", "Cathedral Main"), + "Cathedral - [2F] Entryway Upper Walkway": TunicLocationData("Cathedral", "Cathedral Main"), + "Cathedral - [1F] Library": TunicLocationData("Cathedral", "Cathedral Entry"), + "Cathedral - [2F] Library": TunicLocationData("Cathedral", "Cathedral Main"), + "Cathedral - [2F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral Main"), + "Cathedral - [2F] Bird Room Secret": TunicLocationData("Cathedral", "Cathedral Main"), + "Cathedral - [1F] Library Secret": TunicLocationData("Cathedral", "Cathedral Entry"), "Dark Tomb - Spike Maze Near Exit": TunicLocationData("Dark Tomb", "Dark Tomb Main"), - "Dark Tomb - 2nd Laser Room": TunicLocationData("Dark Tomb", "Dark Tomb Main"), + "Dark Tomb - 2nd Laser Room": TunicLocationData("Dark Tomb", "Dark Tomb Dark Exit"), "Dark Tomb - 1st Laser Room": TunicLocationData("Dark Tomb", "Dark Tomb Main"), "Dark Tomb - Spike Maze Upper Walkway": TunicLocationData("Dark Tomb", "Dark Tomb Main"), "Dark Tomb - Skulls Chest": TunicLocationData("Dark Tomb", "Dark Tomb Upper"), @@ -81,25 +81,25 @@ class TunicLocationData(NamedTuple): "Eastern Vault Fortress - [East Wing] Bombable Wall": TunicLocationData("Eastern Vault Fortress", "Eastern Vault Fortress"), "Eastern Vault Fortress - [West Wing] Page Pickup": TunicLocationData("Eastern Vault Fortress", "Eastern Vault Fortress"), "Fortress Grave Path - Upper Walkway": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path Upper"), - "Fortress Grave Path - Chest Right of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path"), - "Fortress Grave Path - Obscured Chest Left of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path"), + "Fortress Grave Path - Chest Right of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path by Grave"), + "Fortress Grave Path - Obscured Chest Left of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path by Grave"), "Hero's Grave - Flowers Relic": TunicLocationData("Eastern Vault Fortress", "Hero Relic - Fortress"), "Beneath the Fortress - Bridge": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"), "Beneath the Fortress - Cell Chest 1": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"), "Beneath the Fortress - Obscured Behind Waterfall": TunicLocationData("Beneath the Vault", "Beneath the Vault Main"), "Beneath the Fortress - Back Room Chest": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"), "Beneath the Fortress - Cell Chest 2": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"), - "Frog's Domain - Near Vault": TunicLocationData("Frog's Domain", "Frog's Domain"), - "Frog's Domain - Slorm Room": TunicLocationData("Frog's Domain", "Frog's Domain"), + "Frog's Domain - Near Vault": TunicLocationData("Frog's Domain", "Frog's Domain Main"), + "Frog's Domain - Slorm Room": TunicLocationData("Frog's Domain", "Frog's Domain Main"), "Frog's Domain - Escape Chest": TunicLocationData("Frog's Domain", "Frog's Domain Back"), - "Frog's Domain - Grapple Above Hot Tub": TunicLocationData("Frog's Domain", "Frog's Domain"), - "Frog's Domain - Above Vault": TunicLocationData("Frog's Domain", "Frog's Domain"), - "Frog's Domain - Main Room Top Floor": TunicLocationData("Frog's Domain", "Frog's Domain"), - "Frog's Domain - Main Room Bottom Floor": TunicLocationData("Frog's Domain", "Frog's Domain"), - "Frog's Domain - Side Room Secret Passage": TunicLocationData("Frog's Domain", "Frog's Domain"), - "Frog's Domain - Side Room Chest": TunicLocationData("Frog's Domain", "Frog's Domain"), - "Frog's Domain - Side Room Grapple Secret": TunicLocationData("Frog's Domain", "Frog's Domain"), - "Frog's Domain - Magic Orb Pickup": TunicLocationData("Frog's Domain", "Frog's Domain"), + "Frog's Domain - Grapple Above Hot Tub": TunicLocationData("Frog's Domain", "Frog's Domain Front"), + "Frog's Domain - Above Vault": TunicLocationData("Frog's Domain", "Frog's Domain Front"), + "Frog's Domain - Main Room Top Floor": TunicLocationData("Frog's Domain", "Frog's Domain Main"), + "Frog's Domain - Main Room Bottom Floor": TunicLocationData("Frog's Domain", "Frog's Domain Main"), + "Frog's Domain - Side Room Secret Passage": TunicLocationData("Frog's Domain", "Frog's Domain Main"), + "Frog's Domain - Side Room Chest": TunicLocationData("Frog's Domain", "Frog's Domain Main"), + "Frog's Domain - Side Room Grapple Secret": TunicLocationData("Frog's Domain", "Frog's Domain Front"), + "Frog's Domain - Magic Orb Pickup": TunicLocationData("Frog's Domain", "Frog's Domain Main"), "Librarian - Hexagon Green": TunicLocationData("Library", "Library Arena", location_group="Bosses"), "Library Hall - Holy Cross Chest": TunicLocationData("Library", "Library Hall", location_group="Holy Cross"), "Library Lab - Chest By Shrine 2": TunicLocationData("Library", "Library Lab"), @@ -131,7 +131,7 @@ class TunicLocationData(NamedTuple): "Overworld - [Southwest] West Beach Guarded By Turret": TunicLocationData("Overworld", "Overworld Beach"), "Overworld - [Southwest] Chest Guarded By Turret": TunicLocationData("Overworld", "Overworld"), "Overworld - [Northwest] Shadowy Corner Chest": TunicLocationData("Overworld", "Overworld"), - "Overworld - [Southwest] Obscured In Tunnel To Beach": TunicLocationData("Overworld", "Overworld"), + "Overworld - [Southwest] Obscured In Tunnel To Beach": TunicLocationData("Overworld", "Overworld Tunnel to Beach"), "Overworld - [Southwest] Grapple Chest Over Walkway": TunicLocationData("Overworld", "Overworld"), "Overworld - [Northwest] Chest Beneath Quarry Gate": TunicLocationData("Overworld", "Overworld after Envoy"), "Overworld - [Southeast] Chest Near Swamp": TunicLocationData("Overworld", "Overworld Swamp Lower Entry"), @@ -158,7 +158,7 @@ class TunicLocationData(NamedTuple): "Overworld - [Northwest] Page on Pillar by Dark Tomb": TunicLocationData("Overworld", "Overworld"), "Overworld - [Northwest] Fire Wand Pickup": TunicLocationData("Overworld", "Upper Overworld"), "Overworld - [West] Page On Teleporter": TunicLocationData("Overworld", "Overworld"), - "Overworld - [Northwest] Page By Well": TunicLocationData("Overworld", "Overworld"), + "Overworld - [Northwest] Page By Well": TunicLocationData("Overworld", "Overworld Well Entry Area"), "Patrol Cave - Normal Chest": TunicLocationData("Overworld", "Patrol Cave"), "Ruined Shop - Chest 1": TunicLocationData("Overworld", "Ruined Shop"), "Ruined Shop - Chest 2": TunicLocationData("Overworld", "Ruined Shop"), @@ -233,17 +233,17 @@ class TunicLocationData(NamedTuple): "Quarry - [Lowlands] Upper Walkway": TunicLocationData("Lower Quarry", "Even Lower Quarry"), "Quarry - [West] Lower Area Below Bridge": TunicLocationData("Lower Quarry", "Lower Quarry"), "Quarry - [West] Lower Area Isolated Chest": TunicLocationData("Lower Quarry", "Lower Quarry"), - "Quarry - [Lowlands] Near Elevator": TunicLocationData("Lower Quarry", "Even Lower Quarry"), + "Quarry - [Lowlands] Near Elevator": TunicLocationData("Lower Quarry", "Even Lower Quarry Isolated Chest"), "Quarry - [West] Lower Area After Bridge": TunicLocationData("Lower Quarry", "Lower Quarry"), "Rooted Ziggurat Upper - Near Bridge Switch": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Upper Front"), "Rooted Ziggurat Upper - Beneath Bridge To Administrator": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Upper Back"), "Rooted Ziggurat Tower - Inside Tower": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Middle Top"), - "Rooted Ziggurat Lower - Near Corpses": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"), - "Rooted Ziggurat Lower - Spider Ambush": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"), - "Rooted Ziggurat Lower - Left Of Checkpoint Before Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"), - "Rooted Ziggurat Lower - After Guarded Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"), + "Rooted Ziggurat Lower - Near Corpses": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Entry"), + "Rooted Ziggurat Lower - Spider Ambush": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Entry"), + "Rooted Ziggurat Lower - Left Of Checkpoint Before Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"), + "Rooted Ziggurat Lower - After Guarded Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"), "Rooted Ziggurat Lower - Guarded By Double Turrets": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"), - "Rooted Ziggurat Lower - After 2nd Double Turret Chest": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"), + "Rooted Ziggurat Lower - After 2nd Double Turret Chest": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"), "Rooted Ziggurat Lower - Guarded By Double Turrets 2": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"), "Rooted Ziggurat Lower - Hexagon Blue": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Back", location_group="Bosses"), "Ruined Atoll - [West] Near Kevin Block": TunicLocationData("Ruined Atoll", "Ruined Atoll"), @@ -290,26 +290,26 @@ class TunicLocationData(NamedTuple): "Hero's Grave - Feathers Relic": TunicLocationData("Swamp", "Hero Relic - Swamp"), "West Furnace - Chest": TunicLocationData("West Garden", "Furnace Walking Path"), "Overworld - [West] Near West Garden Entrance": TunicLocationData("West Garden", "Overworld to West Garden from Furnace"), - "West Garden - [Central Highlands] Holy Cross (Blue Lines)": TunicLocationData("West Garden", "West Garden", location_group="Holy Cross"), - "West Garden - [West Lowlands] Tree Holy Cross Chest": TunicLocationData("West Garden", "West Garden", location_group="Holy Cross"), - "West Garden - [Southeast Lowlands] Outside Cave": TunicLocationData("West Garden", "West Garden"), - "West Garden - [Central Lowlands] Chest Beneath Faeries": TunicLocationData("West Garden", "West Garden"), - "West Garden - [North] Behind Holy Cross Door": TunicLocationData("West Garden", "West Garden", location_group="Holy Cross"), - "West Garden - [Central Highlands] Top of Ladder Before Boss": TunicLocationData("West Garden", "West Garden"), - "West Garden - [Central Lowlands] Passage Beneath Bridge": TunicLocationData("West Garden", "West Garden"), - "West Garden - [North] Across From Page Pickup": TunicLocationData("West Garden", "West Garden"), - "West Garden - [Central Lowlands] Below Left Walkway": TunicLocationData("West Garden", "West Garden"), - "West Garden - [West] In Flooded Walkway": TunicLocationData("West Garden", "West Garden"), - "West Garden - [West] Past Flooded Walkway": TunicLocationData("West Garden", "West Garden"), - "West Garden - [North] Obscured Beneath Hero's Memorial": TunicLocationData("West Garden", "West Garden"), - "West Garden - [Central Lowlands] Chest Near Shortcut Bridge": TunicLocationData("West Garden", "West Garden"), - "West Garden - [West Highlands] Upper Left Walkway": TunicLocationData("West Garden", "West Garden"), - "West Garden - [Central Lowlands] Chest Beneath Save Point": TunicLocationData("West Garden", "West Garden"), - "West Garden - [Central Highlands] Behind Guard Captain": TunicLocationData("West Garden", "West Garden"), + "West Garden - [Central Highlands] Holy Cross (Blue Lines)": TunicLocationData("West Garden", "West Garden before Boss", location_group="Holy Cross"), + "West Garden - [West Lowlands] Tree Holy Cross Chest": TunicLocationData("West Garden", "West Garden after Terry", location_group="Holy Cross"), + "West Garden - [Southeast Lowlands] Outside Cave": TunicLocationData("West Garden", "West Garden at Dagger House"), + "West Garden - [Central Lowlands] Chest Beneath Faeries": TunicLocationData("West Garden", "West Garden South Checkpoint"), + "West Garden - [North] Behind Holy Cross Door": TunicLocationData("West Garden", "West Garden before Terry", location_group="Holy Cross"), + "West Garden - [Central Highlands] Top of Ladder Before Boss": TunicLocationData("West Garden", "West Garden before Boss"), + "West Garden - [Central Lowlands] Passage Beneath Bridge": TunicLocationData("West Garden", "West Garden after Terry"), + "West Garden - [North] Across From Page Pickup": TunicLocationData("West Garden", "West Garden before Terry"), + "West Garden - [Central Lowlands] Below Left Walkway": TunicLocationData("West Garden", "West Garden after Terry"), + "West Garden - [West] In Flooded Walkway": TunicLocationData("West Garden", "West Garden after Terry"), + "West Garden - [West] Past Flooded Walkway": TunicLocationData("West Garden", "West Garden after Terry"), + "West Garden - [North] Obscured Beneath Hero's Memorial": TunicLocationData("West Garden", "West Garden before Terry"), + "West Garden - [Central Lowlands] Chest Near Shortcut Bridge": TunicLocationData("West Garden", "West Garden after Terry"), + "West Garden - [West Highlands] Upper Left Walkway": TunicLocationData("West Garden", "West Garden South Checkpoint"), + "West Garden - [Central Lowlands] Chest Beneath Save Point": TunicLocationData("West Garden", "West Garden South Checkpoint"), + "West Garden - [Central Highlands] Behind Guard Captain": TunicLocationData("West Garden", "West Garden before Boss"), "West Garden - [Central Highlands] After Garden Knight": TunicLocationData("Overworld", "West Garden after Boss", location_group="Bosses"), - "West Garden - [South Highlands] Secret Chest Beneath Fuse": TunicLocationData("West Garden", "West Garden"), + "West Garden - [South Highlands] Secret Chest Beneath Fuse": TunicLocationData("West Garden", "West Garden South Checkpoint"), "West Garden - [East Lowlands] Page Behind Ice Dagger House": TunicLocationData("West Garden", "West Garden Portal Item"), - "West Garden - [North] Page Pickup": TunicLocationData("West Garden", "West Garden"), + "West Garden - [North] Page Pickup": TunicLocationData("West Garden", "West Garden before Terry"), "West Garden House - [Southeast Lowlands] Ice Dagger Pickup": TunicLocationData("West Garden", "Magic Dagger House"), "Hero's Grave - Effigy Relic": TunicLocationData("West Garden", "Hero Relic - West Garden"), } diff --git a/worlds/tunic/options.py b/worlds/tunic/options.py index f1d53362f4c9..24247a6cfdcf 100644 --- a/worlds/tunic/options.py +++ b/worlds/tunic/options.py @@ -168,6 +168,22 @@ class TunicPlandoConnections(PlandoConnections): duplicate_exits = True +class CombatLogic(Choice): + """ + If enabled, the player will logically require a combination of stat upgrade items and equipment to get some checks or navigate to some areas, with a goal of matching the vanilla combat difficulty. + The player may still be expected to run past enemies, reset aggro (by using a checkpoint or doing a scene transition), or find sneaky paths to checks. + This option marks many more items as progression and may force weapons much earlier than normal. + Bosses Only makes it so that additional combat logic is only added to the boss fights and the Gauntlet. + If disabled, the standard, looser logic is used. The standard logic does not include stat upgrades, just minimal weapon requirements, such as requiring a Sword or Magic Wand for Quarry, or not requiring a weapon for Swamp. + """ + internal_name = "combat_logic" + display_name = "More Combat Logic" + option_off = 0 + option_bosses_only = 1 + option_on = 2 + default = 0 + + class LaurelsZips(Toggle): """ Choose whether to include using the Hero's Laurels to zip through gates, doors, and tricky spots. @@ -259,6 +275,7 @@ class TunicOptions(PerGameCommonOptions): hexagon_goal: HexagonGoal extra_hexagon_percentage: ExtraHexagonPercentage laurels_location: LaurelsLocation + combat_logic: CombatLogic lanternless: Lanternless maskless: Maskless laurels_zips: LaurelsZips @@ -272,6 +289,7 @@ class TunicOptions(PerGameCommonOptions): tunic_option_groups = [ OptionGroup("Logic Options", [ + CombatLogic, Lanternless, Maskless, LaurelsZips, diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index 58c987acbcee..30b7cee9d07b 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -56,9 +56,8 @@ def has_ability(ability: str, state: CollectionState, world: "TunicWorld") -> bo # a check to see if you can whack things in melee at all -def has_stick(state: CollectionState, player: int) -> bool: - return (state.has("Stick", player) or state.has("Sword Upgrade", player, 1) - or state.has("Sword", player)) +def has_melee(state: CollectionState, player: int) -> bool: + return state.has_any({"Stick", "Sword", "Sword Upgrade"}, player) def has_sword(state: CollectionState, player: int) -> bool: @@ -83,7 +82,7 @@ def can_ladder_storage(state: CollectionState, world: "TunicWorld") -> bool: return False if world.options.ladder_storage_without_items: return True - return has_stick(state, world.player) or state.has_any((grapple, shield), world.player) + return has_melee(state, world.player) or state.has_any((grapple, shield), world.player) def has_mask(state: CollectionState, world: "TunicWorld") -> bool: @@ -101,7 +100,7 @@ def set_region_rules(world: "TunicWorld") -> None: world.get_entrance("Overworld -> Overworld Holy Cross").access_rule = \ lambda state: has_ability(holy_cross, state, world) world.get_entrance("Overworld -> Beneath the Well").access_rule = \ - lambda state: has_stick(state, player) or state.has(fire_wand, player) + lambda state: has_melee(state, player) or state.has(fire_wand, player) world.get_entrance("Overworld -> Dark Tomb").access_rule = \ lambda state: has_lantern(state, world) # laurels in, ladder storage in through the furnace, or ice grapple down the belltower @@ -117,7 +116,7 @@ def set_region_rules(world: "TunicWorld") -> None: world.get_entrance("Overworld -> Beneath the Vault").access_rule = \ lambda state: (has_lantern(state, world) and has_ability(prayer, state, world) # there's some boxes in the way - and (has_stick(state, player) or state.has_any((gun, grapple, fire_wand), player))) + and (has_melee(state, player) or state.has_any((gun, grapple, fire_wand), player))) world.get_entrance("Ruined Atoll -> Library").access_rule = \ lambda state: state.has_any({grapple, laurels}, player) and has_ability(prayer, state, world) world.get_entrance("Overworld -> Quarry").access_rule = \ @@ -237,7 +236,7 @@ def set_location_rules(world: "TunicWorld") -> None: or (has_lantern(state, world) and (has_sword(state, player) or state.has(fire_wand, player))) or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)) set_rule(world.get_location("West Furnace - Lantern Pickup"), - lambda state: has_stick(state, player) or state.has_any({fire_wand, laurels}, player)) + lambda state: has_melee(state, player) or state.has_any({fire_wand, laurels}, player)) set_rule(world.get_location("Secret Gathering Place - 10 Fairy Reward"), lambda state: state.has(fairies, player, 10)) @@ -301,18 +300,18 @@ def set_location_rules(world: "TunicWorld") -> None: # Library Lab set_rule(world.get_location("Library Lab - Page 1"), - lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player)) + lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player)) set_rule(world.get_location("Library Lab - Page 2"), - lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player)) + lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player)) set_rule(world.get_location("Library Lab - Page 3"), - lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player)) + lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player)) # Eastern Vault Fortress # yes, you can clear the leaves with dagger # gun isn't included since it can only break one leaf pile at a time, and we don't check how much mana you have # but really, I expect the player to just throw a bomb at them if they don't have melee set_rule(world.get_location("Fortress Leaf Piles - Secret Chest"), - lambda state: state.has(laurels, player) and (has_stick(state, player) or state.has(ice_dagger, player))) + lambda state: state.has(laurels, player) and (has_melee(state, player) or state.has(ice_dagger, player))) set_rule(world.get_location("Fortress Arena - Siege Engine/Vault Key Pickup"), lambda state: has_sword(state, player) and (has_ability(prayer, state, world) @@ -324,9 +323,9 @@ def set_location_rules(world: "TunicWorld") -> None: # Beneath the Vault set_rule(world.get_location("Beneath the Fortress - Bridge"), - lambda state: has_stick(state, player) or state.has_any({laurels, fire_wand}, player)) + lambda state: has_melee(state, player) or state.has_any({laurels, fire_wand}, player)) set_rule(world.get_location("Beneath the Fortress - Obscured Behind Waterfall"), - lambda state: has_stick(state, player) and has_lantern(state, world)) + lambda state: has_melee(state, player) and has_lantern(state, world)) # Quarry set_rule(world.get_location("Quarry - [Central] Above Ladder Dash Chest"), diff --git a/worlds/tunic/test/test_access.py b/worlds/tunic/test/test_access.py index bbceb7468ff3..24551a13d547 100644 --- a/worlds/tunic/test/test_access.py +++ b/worlds/tunic/test/test_access.py @@ -3,6 +3,8 @@ class TestAccess(TunicTestBase): + options = {options.CombatLogic.internal_name: options.CombatLogic.option_off} + # test whether you can get into the temple without laurels def test_temple_access(self) -> None: self.collect_all_but(["Hero's Laurels", "Lantern"]) @@ -61,7 +63,9 @@ def test_normal_goal(self) -> None: class TestER(TunicTestBase): options = {options.EntranceRando.internal_name: options.EntranceRando.option_yes, options.AbilityShuffling.internal_name: options.AbilityShuffling.option_true, - options.HexagonQuest.internal_name: options.HexagonQuest.option_false} + options.HexagonQuest.internal_name: options.HexagonQuest.option_false, + options.CombatLogic.internal_name: options.CombatLogic.option_off, + options.FixedShop.internal_name: options.FixedShop.option_true} def test_overworld_hc_chest(self) -> None: # test to see that static connections are working properly -- this chest requires holy cross and is in Overworld From d1823a21ea891c8d949cc0a5371059b265ff0cb4 Mon Sep 17 00:00:00 2001 From: qwint Date: Sun, 15 Dec 2024 16:48:44 -0500 Subject: [PATCH 031/144] HK: add random handling to plandocharmcosts (#4327) --- worlds/hk/Options.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/worlds/hk/Options.py b/worlds/hk/Options.py index 02f04ab18eef..0dc38e744e50 100644 --- a/worlds/hk/Options.py +++ b/worlds/hk/Options.py @@ -294,6 +294,10 @@ def get_costs(self, random_source: Random) -> typing.List[int]: return charms +class CharmCost(Range): + range_end = 6 + + class PlandoCharmCosts(OptionDict): """Allows setting a Charm's Notch costs directly, mapping {name: cost}. This is set after any random Charm Notch costs, if applicable.""" @@ -303,6 +307,27 @@ class PlandoCharmCosts(OptionDict): Optional(name): And(int, lambda n: 6 >= n >= 0, error="Charm costs must be integers in the range 0-6.") for name in charm_names }) + def __init__(self, value): + # To handle keys of random like other options, create an option instance from their values + # Additionally a vanilla keyword is added to plando individual charms to vanilla costs + # and default is disabled so as to not cause confusion + self.value = {} + for key, data in value.items(): + if isinstance(data, str): + if data.lower() == "vanilla" and key in self.valid_keys: + self.value[key] = vanilla_costs[charm_names.index(key)] + continue + elif data.lower() == "default": + # default is too easily confused with vanilla but actually 0 + # skip CharmCost resolution to fail schema afterwords + self.value[key] = data + continue + try: + self.value[key] = CharmCost.from_any(data).value + except ValueError as ex: + # will fail schema afterwords + self.value[key] = data + def get_costs(self, charm_costs: typing.List[int]) -> typing.List[int]: for name, cost in self.value.items(): charm_costs[charm_names.index(name)] = cost From 728d2492020ee3f75d421a7263308c6feb64e56a Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sun, 15 Dec 2024 23:30:35 +0100 Subject: [PATCH 032/144] Core: Add some more world convenience methods (#3021) * Add some more convenience methods * Typing stuff * Rename the method * beauxq's suggestions * Back to Push Precollected --- worlds/AutoWorld.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index ded8701d3b61..a51071792079 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -7,7 +7,7 @@ import time from random import Random from dataclasses import make_dataclass -from typing import (Any, Callable, ClassVar, Dict, FrozenSet, List, Mapping, Optional, Set, TextIO, Tuple, +from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Mapping, Optional, Set, TextIO, Tuple, TYPE_CHECKING, Type, Union) from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions @@ -534,12 +534,24 @@ def create_filler(self) -> "Item": def get_location(self, location_name: str) -> "Location": return self.multiworld.get_location(location_name, self.player) + def get_locations(self) -> "Iterable[Location]": + return self.multiworld.get_locations(self.player) + def get_entrance(self, entrance_name: str) -> "Entrance": return self.multiworld.get_entrance(entrance_name, self.player) + def get_entrances(self) -> "Iterable[Entrance]": + return self.multiworld.get_entrances(self.player) + def get_region(self, region_name: str) -> "Region": return self.multiworld.get_region(region_name, self.player) + def get_regions(self) -> "Iterable[Region]": + return self.multiworld.get_regions(self.player) + + def push_precollected(self, item: Item) -> None: + self.multiworld.push_precollected(item) + @property def player_name(self) -> str: return self.multiworld.get_player_name(self.player) From cacab68b779a28f8401c5a3a34d26d609054bd75 Mon Sep 17 00:00:00 2001 From: Bryce Wilson Date: Mon, 16 Dec 2024 00:06:48 -0800 Subject: [PATCH 033/144] Pokemon Emerald: Remove unnecessary code (#4364) --- worlds/pokemon_emerald/data.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/worlds/pokemon_emerald/data.py b/worlds/pokemon_emerald/data.py index 34bebae2d66a..cd1becf44b22 100644 --- a/worlds/pokemon_emerald/data.py +++ b/worlds/pokemon_emerald/data.py @@ -1459,9 +1459,6 @@ def _init() -> None: for warp, destination in extracted_data["warps"].items(): data.warp_map[warp] = None if destination == "" else destination - if encoded_warp not in data.warp_map: - data.warp_map[encoded_warp] = None - # Create trainer data for i, trainer_json in enumerate(extracted_data["trainers"]): party_json = trainer_json["party"] From 1ded7b2fd4486d116a8f86c19fb1eb3be210a021 Mon Sep 17 00:00:00 2001 From: Louis M Date: Thu, 19 Dec 2024 20:17:56 -0500 Subject: [PATCH 034/144] Aquaria: Replacing the release link to the latest link (#4381) * Replacing the release link to the latest link * The fr link was not working --- worlds/aquaria/docs/setup_en.md | 4 ++-- worlds/aquaria/docs/setup_fr.md | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/worlds/aquaria/docs/setup_en.md b/worlds/aquaria/docs/setup_en.md index 8177725ded64..b5a71f1ab5f1 100644 --- a/worlds/aquaria/docs/setup_en.md +++ b/worlds/aquaria/docs/setup_en.md @@ -3,11 +3,11 @@ ## Required Software - The original Aquaria Game (purchasable from most online game stores) -- The [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases) +- The [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases/latest) ## Optional Software -- For sending [commands](/tutorial/Archipelago/commands/en) like `!hint`: the TextClient from [the most recent Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases) +- For sending [commands](/tutorial/Archipelago/commands/en) like `!hint`: the TextClient from [the most recent Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases/latest) - [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest), for use with [PopTracker](https://github.com/black-sliver/PopTracker/releases/latest) diff --git a/worlds/aquaria/docs/setup_fr.md b/worlds/aquaria/docs/setup_fr.md index 66b6d6119708..7433dc5dce36 100644 --- a/worlds/aquaria/docs/setup_fr.md +++ b/worlds/aquaria/docs/setup_fr.md @@ -3,12 +3,11 @@ ## Logiciels nécessaires - Une copie du jeu Aquaria non-modifiée (disponible sur la majorité des sites de ventes de jeux vidéos en ligne) -- Le client du Randomizer d'Aquaria [Aquaria randomizer] -(https://github.com/tioui/Aquaria_Randomizer/releases) +- Le client du Randomizer d'Aquaria [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases/latest) ## Logiciels optionnels -- De manière optionnel, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) +- De manière optionnel, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest) - [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest), pour utiliser avec [PopTracker](https://github.com/black-sliver/PopTracker/releases/latest) ## Procédures d'installation et d'exécution From 2e0769c90ec27f155c1de3f6141c641c1f9c341b Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Thu, 19 Dec 2024 20:30:41 -0500 Subject: [PATCH 035/144] Noita: Make greed die a trap (#4382) Noita make greed die a trap --- worlds/noita/items.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/noita/items.py b/worlds/noita/items.py index 1cb7d9601386..394bcdb5757f 100644 --- a/worlds/noita/items.py +++ b/worlds/noita/items.py @@ -53,6 +53,7 @@ def create_random_items(world: NoitaWorld, weights: Dict[str, int], count: int) filler_pool = weights.copy() if not world.options.bad_effects: del filler_pool["Trap"] + del filler_pool["Greed Die"] return world.random.choices(population=list(filler_pool.keys()), weights=list(filler_pool.values()), @@ -114,7 +115,7 @@ def create_all_items(world: NoitaWorld) -> None: "Secret Potion": ItemData(110024, "Items", ItemClassification.filler), "Powder Pouch": ItemData(110025, "Items", ItemClassification.filler), "Chaos Die": ItemData(110026, "Items", ItemClassification.filler), - "Greed Die": ItemData(110027, "Items", ItemClassification.filler), + "Greed Die": ItemData(110027, "Items", ItemClassification.trap), "Kammi": ItemData(110028, "Items", ItemClassification.filler, 1), "Refreshing Gourd": ItemData(110029, "Items", ItemClassification.filler, 1), "Sädekivi": ItemData(110030, "Items", ItemClassification.filler), From de3707af4a4090449a30d228e5b21c3169f30016 Mon Sep 17 00:00:00 2001 From: palex00 <32203971+palex00@users.noreply.github.com> Date: Fri, 20 Dec 2024 02:47:33 +0100 Subject: [PATCH 036/144] Core/Docs: Adding apostrophe quotes around variables in printed error messages (#3914) * Also indents plando_connections properly * Adding apostrophe quotes around item, location, entrance/exit and boss names to make errors more readable * Update plando_en.md * Fixing test in Lufia II --- Options.py | 30 +++++++++++----------- worlds/lufia2ac/test/TestCustomItemPool.py | 4 +-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Options.py b/Options.py index 4e26a0d56c5c..f4724e9747b0 100644 --- a/Options.py +++ b/Options.py @@ -496,7 +496,7 @@ class TextChoice(Choice): def __init__(self, value: typing.Union[str, int]): assert isinstance(value, str) or isinstance(value, int), \ - f"{value} is not a valid option for {self.__class__.__name__}" + f"'{value}' is not a valid option for '{self.__class__.__name__}'" self.value = value @property @@ -617,17 +617,17 @@ def validate_plando_bosses(cls, options: typing.List[str]) -> None: used_locations.append(location) used_bosses.append(boss) if not cls.valid_boss_name(boss): - raise ValueError(f"{boss.title()} is not a valid boss name.") + raise ValueError(f"'{boss.title()}' is not a valid boss name.") if not cls.valid_location_name(location): - raise ValueError(f"{location.title()} is not a valid boss location name.") + raise ValueError(f"'{location.title()}' is not a valid boss location name.") if not cls.can_place_boss(boss, location): - raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.") + raise ValueError(f"'{location.title()}' is not a valid location for {boss.title()} to be placed.") else: if cls.duplicate_bosses: if not cls.valid_boss_name(option): - raise ValueError(f"{option} is not a valid boss name.") + raise ValueError(f"'{option}' is not a valid boss name.") else: - raise ValueError(f"{option.title()} is not formatted correctly.") + raise ValueError(f"'{option.title()}' is not formatted correctly.") @classmethod def can_place_boss(cls, boss: str, location: str) -> bool: @@ -817,15 +817,15 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P for item_name in self.value: if item_name not in world.item_names: picks = get_fuzzy_results(item_name, world.item_names, limit=1) - raise Exception(f"Item {item_name} from option {self} " - f"is not a valid item name from {world.game}. " + raise Exception(f"Item '{item_name}' from option '{self}' " + f"is not a valid item name from '{world.game}'. " f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") elif self.verify_location_name: for location_name in self.value: if location_name not in world.location_names: picks = get_fuzzy_results(location_name, world.location_names, limit=1) - raise Exception(f"Location {location_name} from option {self} " - f"is not a valid location name from {world.game}. " + raise Exception(f"Location '{location_name}' from option '{self}' " + f"is not a valid location name from '{world.game}'. " f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") def __iter__(self) -> typing.Iterator[typing.Any]: @@ -1111,11 +1111,11 @@ def validate_plando_connections(cls, connections: typing.Iterable[PlandoConnecti used_entrances.append(entrance) used_exits.append(exit) if not cls.validate_entrance_name(entrance): - raise ValueError(f"{entrance.title()} is not a valid entrance.") + raise ValueError(f"'{entrance.title()}' is not a valid entrance.") if not cls.validate_exit_name(exit): - raise ValueError(f"{exit.title()} is not a valid exit.") + raise ValueError(f"'{exit.title()}' is not a valid exit.") if not cls.can_connect(entrance, exit): - raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.") + raise ValueError(f"Connection between '{entrance.title()}' and '{exit.title()}' is invalid.") @classmethod def from_any(cls, data: PlandoConFromAnyType) -> Self: @@ -1379,8 +1379,8 @@ def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1) picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else "" - raise Exception(f"Item {item_name} from item link {item_link} " - f"is not a valid item from {world.game} for {pool_name}. " + raise Exception(f"Item '{item_name}' from item link '{item_link}' " + f"is not a valid item from '{world.game}' for '{pool_name}'. " f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}") if allow_item_groups: pool |= world.item_name_groups.get(item_name, {item_name}) diff --git a/worlds/lufia2ac/test/TestCustomItemPool.py b/worlds/lufia2ac/test/TestCustomItemPool.py index 97d4cab2f296..33f72273daae 100644 --- a/worlds/lufia2ac/test/TestCustomItemPool.py +++ b/worlds/lufia2ac/test/TestCustomItemPool.py @@ -50,8 +50,8 @@ class TestVerifyItemName(L2ACTestBase): def test_verify_item_name(self) -> None: self.assertRaisesRegex(Exception, - "Item The car blade from option CustomItemPool\\(The car blade: 2\\) is not a " - "valid item name from Lufia II Ancient Cave\\. Did you mean 'Dekar blade'", + "Item 'The car blade' from option 'CustomItemPool\\(The car blade: 2\\)' is not a " + "valid item name from 'Lufia II Ancient Cave'\\. Did you mean 'Dekar blade'", lambda: handle_option(Namespace(game="Lufia II Ancient Cave", name="Player"), self.options, "custom_item_pool", CustomItemPool, PlandoOptions(0))) From e142283e649d7cba0431d297ee1b1ccfffce5483 Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Thu, 19 Dec 2024 21:19:00 -0500 Subject: [PATCH 037/144] LADX: enable upstream options (#3962) * enable some upstream settings * flashing just disabled, no setting * just enable fast text * noflash and textmode as hidden options * typo * drop whitespace changes * add hard mode to slot data * textmode adjustments fast text default (fixing mistake) remove no text option (its buggy) * unhide options * Update worlds/ladx/Options.py Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * adjustments --- worlds/ladx/LADXR/settings.py | 2 +- worlds/ladx/Options.py | 105 ++++++++++++++++++++++------------ worlds/ladx/__init__.py | 20 +++++-- 3 files changed, 87 insertions(+), 40 deletions(-) diff --git a/worlds/ladx/LADXR/settings.py b/worlds/ladx/LADXR/settings.py index 848d64390de3..a92b6c1e40f4 100644 --- a/worlds/ladx/LADXR/settings.py +++ b/worlds/ladx/LADXR/settings.py @@ -181,7 +181,7 @@ def __init__(self, ap_options): Setting('quickswap', 'User options', 'Q', 'Quickswap', options=[('none', '', 'Disabled'), ('a', 'a', 'Swap A button'), ('b', 'b', 'Swap B button')], default='none', description='Adds that the select button swaps with either A or B. The item is swapped with the top inventory slot. The map is not available when quickswap is enabled.', aesthetic=True), - Setting('textmode', 'User options', 'f', 'Text mode', options=[('fast', '', 'Fast'), ('default', 'd', 'Normal'), ('none', 'n', 'No-text')], default='fast', + Setting('textmode', 'User options', 'f', 'Text mode', options=[('fast', '', 'Fast'), ('normal', 'd', 'Normal'), ('none', 'n', 'No-text')], default='fast', description="""[Fast] makes text appear twice as fast. [No-Text] removes all text from the game""", aesthetic=True), Setting('lowhpbeep', 'User options', 'p', 'Low HP beeps', options=[('none', 'D', 'Disabled'), ('slow', 'S', 'Slow'), ('default', 'N', 'Normal')], default='slow', diff --git a/worlds/ladx/Options.py b/worlds/ladx/Options.py index 17052659157f..afa29e4c28d3 100644 --- a/worlds/ladx/Options.py +++ b/worlds/ladx/Options.py @@ -278,11 +278,21 @@ class MusicChangeCondition(Choice): # [Start with 1] normal game, you just start with 1 heart instead of 3. # [Low max] replace heart containers with heart pieces."""), -# Setting('hardmode', 'Gameplay', 'X', 'Hard mode', options=[('none', '', 'Disabled'), ('oracle', 'O', 'Oracle'), ('hero', 'H', 'Hero'), ('ohko', '1', 'One hit KO')], default='none', -# description=""" -# [Oracle] Less iframes and heath from drops. Bombs damage yourself. Water damages you without flippers. No piece of power or acorn. -# [Hero] Switch version hero mode, double damage, no heart/fairy drops. -# [One hit KO] You die on a single hit, always."""), + +class HardMode(Choice, LADXROption): + """ + [Oracle] Less iframes and health from drops. Bombs damage yourself. Water damages you without flippers. No piece of power or acorn. + [Hero] Switch version hero mode, double damage, no heart/fairy drops. + [One hit KO] You die on a single hit, always. + """ + display_name = "Hard Mode" + ladxr_name = "hardmode" + option_none = 0 + option_oracle = 1 + option_hero = 2 + option_ohko = 3 + default = option_none + # Setting('steal', 'Gameplay', 't', 'Stealing from the shop', # options=[('always', 'a', 'Always'), ('never', 'n', 'Never'), ('default', '', 'Normal')], default='default', @@ -317,35 +327,50 @@ class Overworld(Choice, LADXROption): # Setting('superweapons', 'Special', 'q', 'Enable super weapons', default=False, # description='All items will be more powerful, faster, harder, bigger stronger. You name it.'), -# Setting('quickswap', 'User options', 'Q', 'Quickswap', options=[('none', '', 'Disabled'), ('a', 'a', 'Swap A button'), ('b', 'b', 'Swap B button')], default='none', -# description='Adds that the select button swaps with either A or B. The item is swapped with the top inventory slot. The map is not available when quickswap is enabled.', -# aesthetic=True), -# Setting('textmode', 'User options', 'f', 'Text mode', options=[('fast', '', 'Fast'), ('default', 'd', 'Normal'), ('none', 'n', 'No-text')], default='fast', -# description="""[Fast] makes text appear twice as fast. -# [No-Text] removes all text from the game""", aesthetic=True), -# Setting('lowhpbeep', 'User options', 'p', 'Low HP beeps', options=[('none', 'D', 'Disabled'), ('slow', 'S', 'Slow'), ('default', 'N', 'Normal')], default='slow', -# description='Slows or disables the low health beeping sound', aesthetic=True), -# Setting('noflash', 'User options', 'l', 'Remove flashing lights', default=True, -# description='Remove the flashing light effects from Mamu, shopkeeper and MadBatter. Useful for capture cards and people that are sensitive for these things.', -# aesthetic=True), -# Setting('nagmessages', 'User options', 'S', 'Show nag messages', default=False, -# description='Enables the nag messages normally shown when touching stones and crystals', -# aesthetic=True), -# Setting('gfxmod', 'User options', 'c', 'Graphics', options=gfx_options, default='', -# description='Generally affects at least Link\'s sprite, but can alter any graphics in the game', -# aesthetic=True), -# Setting('linkspalette', 'User options', 'C', "Link's color", -# options=[('-1', '-', 'Normal'), ('0', '0', 'Green'), ('1', '1', 'Yellow'), ('2', '2', 'Red'), ('3', '3', 'Blue'), -# ('4', '4', '?? A'), ('5', '5', '?? B'), ('6', '6', '?? C'), ('7', '7', '?? D')], default='-1', aesthetic=True, -# description="""Allows you to force a certain color on link. -# [Normal] color of link depends on the tunic. -# [Green/Yellow/Red/Blue] forces link into one of these colors. -# [?? A/B/C/D] colors of link are usually inverted and color depends on the area you are in."""), -# Setting('music', 'User options', 'M', 'Music', options=[('', '', 'Default'), ('random', 'r', 'Random'), ('off', 'o', 'Disable')], default='', -# description=""" -# [Random] Randomizes overworld and dungeon music' -# [Disable] no music in the whole game""", -# aesthetic=True), + + +class Quickswap(Choice, LADXROption): + """ + Adds that the SELECT button swaps with either A or B. The item is swapped with the top inventory slot. The map is not available when quickswap is enabled. + """ + display_name = "Quickswap" + ladxr_name = "quickswap" + option_none = 0 + option_a = 1 + option_b = 2 + default = option_none + + +class TextMode(Choice, LADXROption): + """ + [Fast] Makes text appear twice as fast + """ + display_name = "Text Mode" + ladxr_name = "textmode" + option_normal = 0 + option_fast = 1 + default = option_fast + + +class LowHpBeep(Choice, LADXROption): + """ + Slows or disables the low health beeping sound. + """ + display_name = "Low HP Beep" + ladxr_name = "lowhpbeep" + option_default = 0 + option_slow = 1 + option_none = 2 + default = option_default + + +class NoFlash(DefaultOnToggle, LADXROption): + """ + Remove the flashing light effects from Mamu, shopkeeper and MadBatter. Useful for capture cards and people that are sensitive to these things. + """ + display_name = "No Flash" + ladxr_name = "noflash" + class BootsControls(Choice): """ @@ -540,6 +565,8 @@ class ForeignItemIcons(Choice): TrendyGame, InGameHints, NagMessages, + Quickswap, + HardMode, BootsControls ]), OptionGroup("Experimental", [ @@ -554,7 +581,10 @@ class ForeignItemIcons(Choice): APTitleScreen, GfxMod, Music, - MusicChangeCondition + MusicChangeCondition, + LowHpBeep, + TextMode, + NoFlash, ]) ] @@ -597,6 +627,11 @@ class LinksAwakeningOptions(PerGameCommonOptions): nag_messages: NagMessages ap_title_screen: APTitleScreen boots_controls: BootsControls + quickswap: Quickswap + hard_mode: HardMode + low_hp_beep: LowHpBeep + text_mode: TextMode + no_flash: NoFlash in_game_hints: InGameHints warp_improvements: Removed diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index 8496d4cf49e3..b416bfd0bfc1 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -514,10 +514,22 @@ def fill_slot_data(self): slot_options = ["instrument_count"] slot_options_display_name = [ - "goal", "logic", "tradequest", "rooster", - "experimental_dungeon_shuffle", "experimental_entrance_shuffle", "trendy_game", "gfxmod", - "shuffle_nightmare_keys", "shuffle_small_keys", "shuffle_maps", - "shuffle_compasses", "shuffle_stone_beaks", "shuffle_instruments", "nag_messages" + "goal", + "logic", + "tradequest", + "rooster", + "experimental_dungeon_shuffle", + "experimental_entrance_shuffle", + "trendy_game", + "gfxmod", + "shuffle_nightmare_keys", + "shuffle_small_keys", + "shuffle_maps", + "shuffle_compasses", + "shuffle_stone_beaks", + "shuffle_instruments", + "nag_messages", + "hard_mode", ] # use the default behaviour to grab options From 4f71073d174c58c7418b83e2f46960fd1ccc9fd3 Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Thu, 19 Dec 2024 22:17:41 -0500 Subject: [PATCH 038/144] LADX: correct in-game check counter LADX: correct in-game check counter --- worlds/ladx/LADXR/patches/bank3e.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/worlds/ladx/LADXR/patches/bank3e.py b/worlds/ladx/LADXR/patches/bank3e.py index 632fffa7e63e..8195bf3ff3f6 100644 --- a/worlds/ladx/LADXR/patches/bank3e.py +++ b/worlds/ladx/LADXR/patches/bank3e.py @@ -96,7 +96,9 @@ def get_asm(name): ldi [hl], a ;hour counter ld hl, $B010 + ld a, $01 ;tarin's gift gets skipped for some reason, so inflate count by 1 ldi [hl], a ;check counter low + xor a ldi [hl], a ;check counter high ; Show the normal message From 35d30442f70ddaebadb3617a97e2841dc27bda2d Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Thu, 19 Dec 2024 22:53:58 -0500 Subject: [PATCH 039/144] LADX: fix for syntax warning (#4376) * init * whitespace * raw string instead --- LinksAwakeningClient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index 298788098d9e..aede742b82a0 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -235,7 +235,7 @@ async def async_read_memory_safe(self, address, size=1): def check_command_response(self, command: str, response: bytes): if command == "VERSION": - ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None + ok = re.match(r"\d+\.\d+\.\d+", response.decode('ascii')) is not None else: ok = response.startswith(command.encode()) if not ok: From 7c8d102c1760b519dfdc2fd5143849cc4db162e6 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Thu, 19 Dec 2024 23:45:29 -0500 Subject: [PATCH 040/144] TUNIC: Logic for bushes in guard house 2 upper and belltower (#4371) * Logic for bushes in guard house 2 upper * Fix typo * also do it for forest belltower * i love the dumb ice grapples --- worlds/tunic/er_data.py | 25 +++++++++++++++++++------ worlds/tunic/er_rules.py | 25 +++++++++++++++++++++++-- worlds/tunic/locations.py | 2 +- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/worlds/tunic/er_data.py b/worlds/tunic/er_data.py index 9794f4a87b67..1dc06d586d6f 100644 --- a/worlds/tunic/er_data.py +++ b/worlds/tunic/er_data.py @@ -175,7 +175,7 @@ def destination_scene(self) -> str: # the vanilla connection Portal(name="Temple Door Exit", region="Sealed Temple", destination="Overworld Redux", tag="_main"), - Portal(name="Forest Belltower to Fortress", region="Forest Belltower Main", + Portal(name="Forest Belltower to Fortress", region="Forest Belltower Main behind bushes", destination="Fortress Courtyard", tag="_"), Portal(name="Forest Belltower to Forest", region="Forest Belltower Lower", destination="East Forest Redux", tag="_"), @@ -221,7 +221,7 @@ def destination_scene(self) -> str: # the vanilla connection Portal(name="Guard House 2 Lower Exit", region="Guard House 2 Lower", destination="East Forest Redux", tag="_lower"), - Portal(name="Guard House 2 Upper Exit", region="Guard House 2 Upper", + Portal(name="Guard House 2 Upper Exit", region="Guard House 2 Upper before bushes", destination="East Forest Redux", tag="_upper"), Portal(name="Guard Captain Room Non-Gate Exit", region="Forest Boss Room", @@ -601,6 +601,7 @@ class DeadEnd(IntEnum): "Sealed Temple Rafters": RegionInfo("Temple"), "Forest Belltower Upper": RegionInfo("Forest Belltower"), "Forest Belltower Main": RegionInfo("Forest Belltower"), + "Forest Belltower Main behind bushes": RegionInfo("Forest Belltower"), "Forest Belltower Lower": RegionInfo("Forest Belltower"), "East Forest": RegionInfo("East Forest Redux"), "East Forest Dance Fox Spot": RegionInfo("East Forest Redux"), @@ -608,7 +609,8 @@ class DeadEnd(IntEnum): "Lower Forest": RegionInfo("East Forest Redux"), # bottom of the forest "Guard House 1 East": RegionInfo("East Forest Redux Laddercave"), "Guard House 1 West": RegionInfo("East Forest Redux Laddercave"), - "Guard House 2 Upper": RegionInfo("East Forest Redux Interior"), + "Guard House 2 Upper before bushes": RegionInfo("East Forest Redux Interior"), + "Guard House 2 Upper after bushes": RegionInfo("East Forest Redux Interior"), "Guard House 2 Lower": RegionInfo("East Forest Redux Interior"), "Forest Boss Room": RegionInfo("Forest Boss Room"), "Forest Grave Path Main": RegionInfo("Sword Access"), @@ -1026,6 +1028,12 @@ class DeadEnd(IntEnum): "Forest Belltower Main": { "Forest Belltower Lower": [], + "Forest Belltower Main behind bushes": + [], + }, + "Forest Belltower Main behind bushes": { + "Forest Belltower Main": + [], }, "East Forest": { @@ -1057,13 +1065,18 @@ class DeadEnd(IntEnum): "Guard House 1 East": [["Hyperdash"], ["LS1"]], }, - - "Guard House 2 Upper": { + "Guard House 2 Upper before bushes": { + "Guard House 2 Upper after bushes": + [], + }, + "Guard House 2 Upper after bushes": { "Guard House 2 Lower": [], + "Guard House 2 Upper before bushes": + [], }, "Guard House 2 Lower": { - "Guard House 2 Upper": + "Guard House 2 Upper after bushes": [], }, diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 163523108345..6e9ae551dba2 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -40,6 +40,12 @@ def can_shop(state: CollectionState, world: "TunicWorld") -> bool: return has_sword(state, world.player) and state.can_reach_region("Shop", world.player) +# for the ones that are not early bushes where ER can screw you over a bit +def can_get_past_bushes(state: CollectionState, world: "TunicWorld") -> bool: + # add in glass cannon + stick for grass rando + return has_sword(state, world.player) or state.has_any((fire_wand, laurels, gun), world.player) + + def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_pairs: Dict[Portal, Portal]) -> None: player = world.player options = world.options @@ -437,6 +443,14 @@ def get_paired_portal(portal_sd: str) -> Tuple[str, str]: connecting_region=regions["Forest Belltower Lower"], rule=lambda state: has_ladder("Ladder to East Forest", state, world)) + regions["Forest Belltower Main behind bushes"].connect( + connecting_region=regions["Forest Belltower Main"], + rule=lambda state: can_get_past_bushes(state, world) + or has_ice_grapple_logic(False, IceGrappling.option_easy, state, world)) + # you can use the slimes to break the bushes + regions["Forest Belltower Main"].connect( + connecting_region=regions["Forest Belltower Main behind bushes"]) + # ice grapple up to dance fox spot, and vice versa regions["East Forest"].connect( connecting_region=regions["East Forest Dance Fox Spot"], @@ -467,11 +481,18 @@ def get_paired_portal(portal_sd: str) -> Tuple[str, str]: connecting_region=regions["Guard House 1 East"], rule=lambda state: state.has(laurels, player)) - regions["Guard House 2 Upper"].connect( + regions["Guard House 2 Upper before bushes"].connect( + connecting_region=regions["Guard House 2 Upper after bushes"], + rule=lambda state: can_get_past_bushes(state, world)) + regions["Guard House 2 Upper after bushes"].connect( + connecting_region=regions["Guard House 2 Upper before bushes"], + rule=lambda state: can_get_past_bushes(state, world)) + + regions["Guard House 2 Upper after bushes"].connect( connecting_region=regions["Guard House 2 Lower"], rule=lambda state: has_ladder("Ladders to Lower Forest", state, world)) regions["Guard House 2 Lower"].connect( - connecting_region=regions["Guard House 2 Upper"], + connecting_region=regions["Guard House 2 Upper after bushes"], rule=lambda state: has_ladder("Ladders to Lower Forest", state, world)) # ice grapple from upper grave path exit to the rest of it diff --git a/worlds/tunic/locations.py b/worlds/tunic/locations.py index 5ea309fb19d7..c44852e8aab8 100644 --- a/worlds/tunic/locations.py +++ b/worlds/tunic/locations.py @@ -41,7 +41,7 @@ class TunicLocationData(NamedTuple): "Dark Tomb - Skulls Chest": TunicLocationData("Dark Tomb", "Dark Tomb Upper"), "Dark Tomb - Spike Maze Near Stairs": TunicLocationData("Dark Tomb", "Dark Tomb Main"), "Dark Tomb - 1st Laser Room Obscured": TunicLocationData("Dark Tomb", "Dark Tomb Main"), - "Guardhouse 2 - Upper Floor": TunicLocationData("East Forest", "Guard House 2 Upper"), + "Guardhouse 2 - Upper Floor": TunicLocationData("East Forest", "Guard House 2 Upper after bushes"), "Guardhouse 2 - Bottom Floor Secret": TunicLocationData("East Forest", "Guard House 2 Lower"), "Guardhouse 1 - Upper Floor Obscured": TunicLocationData("East Forest", "Guard House 1 East"), "Guardhouse 1 - Upper Floor": TunicLocationData("East Forest", "Guard House 1 East"), From e1a1cd10678ce4170a6ae5b609db815c516af193 Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Fri, 20 Dec 2024 07:55:32 -0500 Subject: [PATCH 041/144] LADX: Open Mabe Option (#3964) * open mabe option swaps east mabe rocks for bushes * add open mabe to slot data * use upstream overworld option Instead of a standalone option, use upstream's "overworld" option, which we don't use yet but it leaves better space for the future * use ladxr_setting for consistency * newline --- worlds/ladx/LADXR/generator.py | 9 ++++---- worlds/ladx/LADXR/logic/overworld.py | 7 +++++- worlds/ladx/LADXR/patches/maptweaks.py | 9 ++++++++ worlds/ladx/LADXR/settings.py | 2 +- worlds/ladx/Options.py | 30 ++++++++++++-------------- worlds/ladx/__init__.py | 1 + 6 files changed, 36 insertions(+), 22 deletions(-) diff --git a/worlds/ladx/LADXR/generator.py b/worlds/ladx/LADXR/generator.py index 046b51815cba..ff6cc06c39a9 100644 --- a/worlds/ladx/LADXR/generator.py +++ b/worlds/ladx/LADXR/generator.py @@ -58,7 +58,6 @@ from .patches import bank34 from .utils import formatText -from ..Options import TrendyGame, Palette, Warps from .roomEditor import RoomEditor, Object from .patches.aesthetics import rgb_to_bin, bin_to_rgb @@ -66,7 +65,7 @@ from BaseClasses import ItemClassification from ..Locations import LinksAwakeningLocation -from ..Options import TrendyGame, Palette, MusicChangeCondition, BootsControls +from ..Options import TrendyGame, Palette, MusicChangeCondition, Warps if TYPE_CHECKING: from .. import LinksAwakeningWorld @@ -156,6 +155,8 @@ def generateRom(args, world: "LinksAwakeningWorld"): if not world.ladxr_settings.rooster: patches.maptweaks.tweakMap(rom) patches.maptweaks.tweakBirdKeyRoom(rom) + if world.ladxr_settings.overworld == "openmabe": + patches.maptweaks.openMabe(rom) patches.chest.fixChests(rom) patches.shop.fixShop(rom) patches.rooster.patchRooster(rom) @@ -247,7 +248,7 @@ def generateRom(args, world: "LinksAwakeningWorld"): patches.core.quickswap(rom, 1) elif world.ladxr_settings.quickswap == 'b': patches.core.quickswap(rom, 0) - + patches.core.addBootsControls(rom, world.options.boots_controls) @@ -397,7 +398,7 @@ def gen_hint(): # Then put new text in for bucket_idx, (orig_idx, data) in enumerate(bucket): rom.texts[shuffled[bucket_idx][0]] = data - + if world.options.trendy_game != TrendyGame.option_normal: diff --git a/worlds/ladx/LADXR/logic/overworld.py b/worlds/ladx/LADXR/logic/overworld.py index 54da90f8931d..a85a97ae6451 100644 --- a/worlds/ladx/LADXR/logic/overworld.py +++ b/worlds/ladx/LADXR/logic/overworld.py @@ -144,7 +144,12 @@ def __init__(self, options, world_setup, r): self._addEntrance("moblin_cave", graveyard, moblin_cave, None) # "Ukuku Prairie" - ukuku_prairie = Location().connect(mabe_village, POWER_BRACELET).connect(graveyard, POWER_BRACELET) + ukuku_prairie = Location() + if options.overworld == "openmabe": + ukuku_prairie.connect(mabe_village, r.bush) + else: + ukuku_prairie.connect(mabe_village, POWER_BRACELET) + ukuku_prairie.connect(graveyard, POWER_BRACELET) ukuku_prairie.connect(Location().add(TradeSequenceItem(0x07B, TRADING_ITEM_STICK)), TRADING_ITEM_BANANAS) ukuku_prairie.connect(Location().add(TradeSequenceItem(0x087, TRADING_ITEM_HONEYCOMB)), TRADING_ITEM_STICK) self._addEntrance("prairie_left_phone", ukuku_prairie, None, None) diff --git a/worlds/ladx/LADXR/patches/maptweaks.py b/worlds/ladx/LADXR/patches/maptweaks.py index 8a5171b3540d..2d69f79cebc8 100644 --- a/worlds/ladx/LADXR/patches/maptweaks.py +++ b/worlds/ladx/LADXR/patches/maptweaks.py @@ -38,3 +38,12 @@ def tweakBirdKeyRoom(rom): re.moveObject(2, 5, 3, 6) re.addEntity(3, 5, 0x9D) re.store(rom) + + +def openMabe(rom): + # replaces rocks on east side of Mabe Village with bushes + re = RoomEditor(rom, 0x094) + re.changeObject(5, 1, 0x5C) + re.overlay[5 + 1 * 10] = 0x5C + re.overlay[5 + 2 * 10] = 0x5C + re.store(rom) diff --git a/worlds/ladx/LADXR/settings.py b/worlds/ladx/LADXR/settings.py index a92b6c1e40f4..3b8407c147d1 100644 --- a/worlds/ladx/LADXR/settings.py +++ b/worlds/ladx/LADXR/settings.py @@ -169,7 +169,7 @@ def __init__(self, ap_options): [Never] you can never steal from the shop."""), Setting('bowwow', 'Special', 'g', 'Good boy mode', options=[('normal', '', 'Disabled'), ('always', 'a', 'Enabled'), ('swordless', 's', 'Enabled (swordless)')], default='normal', description='Allows BowWow to be taken into any area, damage bosses and more enemies. If enabled you always start with bowwow. Swordless option removes the swords from the game and requires you to beat the game without a sword and just bowwow.'), - Setting('overworld', 'Special', 'O', 'Overworld', options=[('normal', '', 'Normal'), ('dungeondive', 'D', 'Dungeon dive'), ('nodungeons', 'N', 'No dungeons'), ('random', 'R', 'Randomized')], default='normal', + Setting('overworld', 'Special', 'O', 'Overworld', options=[('normal', '', 'Normal'), ('dungeondive', 'D', 'Dungeon dive'), ('nodungeons', 'N', 'No dungeons'), ('random', 'R', 'Randomized'), ('openmabe', 'M', 'Open Mabe')], default='normal', description=""" [Dungeon Dive] Create a different overworld where all the dungeons are directly accessible and almost no chests are located in the overworld. [No dungeons] All dungeons only consist of a boss fight and a instrument reward. Rest of the dungeon is removed. diff --git a/worlds/ladx/Options.py b/worlds/ladx/Options.py index afa29e4c28d3..d92bd931867d 100644 --- a/worlds/ladx/Options.py +++ b/worlds/ladx/Options.py @@ -57,7 +57,7 @@ class TextShuffle(DefaultOffToggle): class Rooster(DefaultOnToggle, LADXROption): """ - [On] Adds the rooster to the item pool. + [On] Adds the rooster to the item pool. [Off] The rooster spot is still a check giving an item. But you will never find the rooster. In that case, any rooster spot is accessible without rooster by other means. """ display_name = "Rooster" @@ -70,7 +70,7 @@ class Boomerang(Choice): [Gift] The boomerang salesman will give you a random item, and the boomerang is shuffled. """ display_name = "Boomerang" - + normal = 0 gift = 1 default = gift @@ -156,7 +156,7 @@ class ShuffleSmallKeys(DungeonItemShuffle): [Own Dungeons] The item will be within a dungeon in your world [Own World] The item will be somewhere in your world [Any World] The item could be anywhere - [Different World] The item will be somewhere in another world + [Different World] The item will be somewhere in another world """ display_name = "Shuffle Small Keys" ladxr_item = "KEY" @@ -223,7 +223,7 @@ class Goal(Choice, LADXROption): The Goal of the game [Instruments] The Wind Fish's Egg will only open if you have the required number of Instruments of the Sirens, and play the Ballad of the Wind Fish. [Seashells] The Egg will open when you bring 20 seashells. The Ballad and Ocarina are not needed. - [Open] The Egg will start pre-opened. + [Open] The Egg will start pre-opened. """ display_name = "Goal" ladxr_name = "goal" @@ -313,15 +313,12 @@ class Bowwow(Choice): class Overworld(Choice, LADXROption): """ - [Dungeon Dive] Create a different overworld where all the dungeons are directly accessible and almost no chests are located in the overworld. - [Tiny dungeons] All dungeons only consist of a boss fight and a instrument reward. Rest of the dungeon is removed. + [Open Mabe] Replaces rock on the east side of Mabe Village with bushes, allowing access to Ukuku Prairie without Power Bracelet. """ display_name = "Overworld" ladxr_name = "overworld" option_normal = 0 - option_dungeon_dive = 1 - option_tiny_dungeons = 2 - # option_shuffled = 3 + option_open_mabe = 1 default = option_normal @@ -472,7 +469,7 @@ def to_ladxr_option(self, all_options): class Palette(Choice): """ - Sets the palette for the game. + Sets the palette for the game. Note: A few places aren't patched, such as the menu and a few color dungeon tiles. [Normal] The vanilla palette [1-Bit] One bit of color per channel @@ -530,7 +527,6 @@ class InGameHints(DefaultOnToggle): display_name = "In-game Hints" - class ForeignItemIcons(Choice): """ Choose how to display foreign items. @@ -562,6 +558,7 @@ class ForeignItemIcons(Choice): OptionGroup("Miscellaneous", [ TradeQuest, Rooster, + Overworld, TrendyGame, InGameHints, NagMessages, @@ -591,12 +588,12 @@ class ForeignItemIcons(Choice): @dataclass class LinksAwakeningOptions(PerGameCommonOptions): logic: Logic - # 'heartpiece': DefaultOnToggle, # description='Includes heart pieces in the item pool'), - # 'seashells': DefaultOnToggle, # description='Randomizes the secret sea shells hiding in the ground/trees. (chest are always randomized)'), - # 'heartcontainers': DefaultOnToggle, # description='Includes boss heart container drops in the item pool'), - # 'instruments': DefaultOffToggle, # description='Instruments are placed on random locations, dungeon goal will just contain a random item.'), + # 'heartpiece': DefaultOnToggle, # description='Includes heart pieces in the item pool'), + # 'seashells': DefaultOnToggle, # description='Randomizes the secret sea shells hiding in the ground/trees. (chest are always randomized)'), + # 'heartcontainers': DefaultOnToggle, # description='Includes boss heart container drops in the item pool'), + # 'instruments': DefaultOffToggle, # description='Instruments are placed on random locations, dungeon goal will just contain a random item.'), tradequest: TradeQuest # description='Trade quest items are randomized, each NPC takes its normal trade quest item, but gives a random item'), - # 'witch': DefaultOnToggle, # description='Adds both the toadstool and the reward for giving the toadstool to the witch to the item pool'), + # 'witch': DefaultOnToggle, # description='Adds both the toadstool and the reward for giving the toadstool to the witch to the item pool'), rooster: Rooster # description='Adds the rooster to the item pool. Without this option, the rooster spot is still a check giving an item. But you will never find the rooster. Any rooster spot is accessible without rooster by other means.'), # 'boomerang': Boomerang, # 'randomstartlocation': DefaultOffToggle, # 'Randomize where your starting house is located'), @@ -633,6 +630,7 @@ class LinksAwakeningOptions(PerGameCommonOptions): text_mode: TextMode no_flash: NoFlash in_game_hints: InGameHints + overworld: Overworld warp_improvements: Removed additional_warp_points: Removed diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index b416bfd0bfc1..b8de6da812df 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -530,6 +530,7 @@ def fill_slot_data(self): "shuffle_instruments", "nag_messages", "hard_mode", + "overworld", ] # use the default behaviour to grab options From 46613adceb4c34676f1bbda29b0fe2960667c19a Mon Sep 17 00:00:00 2001 From: Kaito Sinclaire Date: Sat, 21 Dec 2024 11:39:38 -0800 Subject: [PATCH 042/144] SMZ3: Fix minimal logic considering SM boss tokens unnecessary (#4377) --- worlds/smz3/TotalSMZ3/Regions/Zelda/GanonsTower.py | 3 ++- worlds/smz3/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/worlds/smz3/TotalSMZ3/Regions/Zelda/GanonsTower.py b/worlds/smz3/TotalSMZ3/Regions/Zelda/GanonsTower.py index 42933b9f2fd5..e17d7072258c 100644 --- a/worlds/smz3/TotalSMZ3/Regions/Zelda/GanonsTower.py +++ b/worlds/smz3/TotalSMZ3/Regions/Zelda/GanonsTower.py @@ -140,7 +140,8 @@ def CanEnter(self, items: Progression): # added for AP completion_condition when TowerCrystals is lower than GanonCrystals def CanComplete(self, items: Progression): - return self.world.CanAcquireAtLeast(self.world.GanonCrystals, items, RewardType.AnyCrystal) + return self.world.CanAcquireAtLeast(self.world.GanonCrystals, items, RewardType.AnyCrystal) and \ + self.world.CanAcquireAtLeast(self.world.TourianBossTokens, items, RewardType.AnyBossToken) def CanFill(self, item: Item): if (self.Config.Multiworld): diff --git a/worlds/smz3/__init__.py b/worlds/smz3/__init__.py index 838db1f7e745..5998db8e6579 100644 --- a/worlds/smz3/__init__.py +++ b/worlds/smz3/__init__.py @@ -230,7 +230,7 @@ def create_items(self): self.multiworld.itempool += itemPool def set_rules(self): - # SM G4 is logically required to access Ganon's Tower in SMZ3 + # SM G4 is logically required to complete Ganon's Tower self.multiworld.completion_condition[self.player] = lambda state: \ self.smz3World.GetRegion("Ganon's Tower").CanEnter(state.smz3state[self.player]) and \ self.smz3World.GetRegion("Ganon's Tower").TowerAscend(state.smz3state[self.player]) and \ From 4f590cdf7b5aa2be95e4ed9cf7c7be95443456e8 Mon Sep 17 00:00:00 2001 From: DrBibop <58860289+DrBibop@users.noreply.github.com> Date: Sat, 21 Dec 2024 17:12:35 -0500 Subject: [PATCH 043/144] Inscryption: Implement new game (#3621) * Worked locally before that so this is a lot of work . So, initial push * Changes in init with better create_regions (Thanks to Phar on discord). Add a rule for victory. Change the regions list to remove menu in the destination. * Added tests for location rules and changed rule locations to lists instead of sets * Fixed game var in InscryptionLocation * Fixed location access by using the same system from The Messenger * Remove unuse rules in init and add region rules. Add all the act 2 locations and items. * Add locations rule for the left of the bridge in act 2 * Added test for bridge requirement and added a dash to locationfor clarity * Added more act 2 rules and removed completion rule * Created docs for website, added Salmon Card item, marked multiple items as "progression", renamed tomb checks, added more location rules, re-added completion rule * Renamed tower bath check to "Tentacle", added monocle as requirement for some checks, adjusted setup doc a bit * Added tentacle to monocle test * Added forest burrow chest rule * Switch the two clock location because the id was swapped and screwed with the logic * Added Ancient Obol rule and adjusted docs * Added act 3 locations/items/rules/tests * Added drone & battery to trader rules * Fixed tutorial docs, added more act 3 rules, renamed holo pelt locations * Add an option for the optional death card feature * Added well check and quill item, added rules and tests * Renamed Gems module and Gems drone * Added slot data options * Added rule for act 3 middle pelt * Added option for randomize ability and uptade the randomize deck option to fit the new setup * Added randomize ability in slot data * Added more requirements for mycologists boss since it's pretty much an impossible fight early on * Finished the french translation of the installation guide * Changed the french title in the guide * Added goal option and tests associated to it + fixed goal requirement missing quill * Added goal option to docs and removed references to the now discarded API mod. Fixed some french translations. * Added ourobot item + renamed some goal settings * Fixed locations and items for act 1 goal * Added skip tutorial option. Cleanup and rename of some options. Added tower requirement for Mycologist Key check. Fixed missing comma in act 2 locations oopsies. * Added missing rules for Extra Battery, Nano Armor and Goobert's painting * Added act 1 deathlink behaviour and epitaph pieces randomization options + made pieces progressive + adjusted docs * Fixed some docs typos * Added act 3 clock rule. Paintings 2, 3 and Goobert's painting can no longer contain progression items. * New options system and fixed act 1 goal option breaking * Added skip epilogue and painting checks balancing options. Renamed randomize abilities to randomize sigils. Fixed generation issue with epitaph pieces randomization. Goobert's painting no longer forces filler. Removed traps option for now. Reworded some option descriptions. * Attempting type fix for python 3.8 * Attempting type fix for python 3.8 again * Added starting only option for randomize deck * Fixed arbitrary rule error * Import fix attempt * Migrated to DeathLinkMixin instead of creating a custom DeathLink option, cleaned up imports, renamed Death Link related options to include "death_link" instead of "deathlink", replaced numeral values for option checking into class attributes for readability, slight optimization to tower rule, fixed typo in codes option description. * Added bug report page to web class, condensed pelt rules to one function, added items/locations count in game docs and adjusted some sections * Added Inscryption to CODEOWNERS * Implemented a bunch of suggestions: Better handling of painting option, options as dict for slot data, remove redundant auto_display_name, use of has_all, better goal tests, demote skink card to filler if goal is act 1 and force filler on paintings * Makes clover plant and squirrel head progression items if paintings are balanced + fixed other issues * filler items, start inventory from pool, '->" * Fix bleeding issue * Copy the list instead * Fixed bleeding using proper deep copy * Remove unnecessary for loops in tests * Add defaults to choice options --------- Co-authored-by: Benjamin Gregoire Co-authored-by: Exempt-Medic Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> --- README.md | 1 + docs/CODEOWNERS | 3 + worlds/inscryption/Items.py | 158 ++++++++++++++++ worlds/inscryption/Locations.py | 127 +++++++++++++ worlds/inscryption/Options.py | 137 ++++++++++++++ worlds/inscryption/Regions.py | 14 ++ worlds/inscryption/Rules.py | 181 ++++++++++++++++++ worlds/inscryption/__init__.py | 144 ++++++++++++++ worlds/inscryption/docs/en_Inscryption.md | 22 +++ worlds/inscryption/docs/setup_en.md | 65 +++++++ worlds/inscryption/docs/setup_fr.md | 67 +++++++ worlds/inscryption/test/TestAccess.py | 221 ++++++++++++++++++++++ worlds/inscryption/test/TestGoal.py | 108 +++++++++++ worlds/inscryption/test/__init__.py | 7 + 14 files changed, 1255 insertions(+) create mode 100644 worlds/inscryption/Items.py create mode 100644 worlds/inscryption/Locations.py create mode 100644 worlds/inscryption/Options.py create mode 100644 worlds/inscryption/Regions.py create mode 100644 worlds/inscryption/Rules.py create mode 100644 worlds/inscryption/__init__.py create mode 100644 worlds/inscryption/docs/en_Inscryption.md create mode 100644 worlds/inscryption/docs/setup_en.md create mode 100644 worlds/inscryption/docs/setup_fr.md create mode 100644 worlds/inscryption/test/TestAccess.py create mode 100644 worlds/inscryption/test/TestGoal.py create mode 100644 worlds/inscryption/test/__init__.py diff --git a/README.md b/README.md index 36b7a07fb4b3..d60f1b96651f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Currently, the following games are supported: * Faxanadu * Saving Princess * Castlevania: Circle of the Moon +* Inscryption For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index 8b39f96068af..d58207806743 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -81,6 +81,9 @@ # Hylics 2 /worlds/hylics2/ @TRPG0 +# Inscryption +/worlds/inscryption/ @DrBibop @Glowbuzz + # Kirby's Dream Land 3 /worlds/kdl3/ @Silvris diff --git a/worlds/inscryption/Items.py b/worlds/inscryption/Items.py new file mode 100644 index 000000000000..7600830ac9e2 --- /dev/null +++ b/worlds/inscryption/Items.py @@ -0,0 +1,158 @@ +from BaseClasses import ItemClassification +from typing import TypedDict, List + +from BaseClasses import Item + + +base_id = 147000 + + +class InscryptionItem(Item): + name: str = "Inscryption" + + +class ItemDict(TypedDict): + name: str + count: int + classification: ItemClassification + + +act1_items: List[ItemDict] = [ + {'name': "Stinkbug Card", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Stunted Wolf Card", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Wardrobe Key", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Skink Card", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Ant Cards", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Caged Wolf Card", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Squirrel Totem Head", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Dagger", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Film Roll", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Ring", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Magnificus Eye", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Oil Painting's Clover Plant", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Extra Candle", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Bee Figurine", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Greater Smoke", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Angler Hook", + 'count': 1, + 'classification': ItemClassification.useful} +] + + +act2_items: List[ItemDict] = [ + {'name': "Camera Replica", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Pile Of Meat", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Epitaph Piece", + 'count': 9, + 'classification': ItemClassification.progression}, + {'name': "Epitaph Pieces", + 'count': 3, + 'classification': ItemClassification.progression}, + {'name': "Monocle", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Bone Lord Femur", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Bone Lord Horn", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Bone Lord Holo Key", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Mycologists Holo Key", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Ancient Obol", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Great Kraken Card", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Drowned Soul Card", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Salmon Card", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Dock's Clover Plant", + 'count': 1, + 'classification': ItemClassification.useful} +] + + +act3_items: List[ItemDict] = [ + {'name': "Extra Battery", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Nano Armor Generator", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Mrs. Bomb's Remote", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Inspectometer Battery", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Gems Module", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Lonely Wizbot Card", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Fishbot Card", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Ourobot Card", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Holo Pelt", + 'count': 5, + 'classification': ItemClassification.progression}, + {'name': "Quill", + 'count': 1, + 'classification': ItemClassification.progression}, +] + +filler_items: List[ItemDict] = [ + {'name': "Currency", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Card Pack", + 'count': 1, + 'classification': ItemClassification.filler} +] diff --git a/worlds/inscryption/Locations.py b/worlds/inscryption/Locations.py new file mode 100644 index 000000000000..aa124c23e06b --- /dev/null +++ b/worlds/inscryption/Locations.py @@ -0,0 +1,127 @@ +from typing import Dict, List + +from BaseClasses import Location + +base_id = 147000 + + +class InscryptionLocation(Location): + game: str = "Inscryption" + + +act1_locations = [ + "Act 1 - Boss Prospector", + "Act 1 - Boss Angler", + "Act 1 - Boss Trapper", + "Act 1 - Boss Leshy", + "Act 1 - Safe", + "Act 1 - Clock Main Compartment", + "Act 1 - Clock Upper Compartment", + "Act 1 - Dagger", + "Act 1 - Wardrobe Drawer 1", + "Act 1 - Wardrobe Drawer 2", + "Act 1 - Wardrobe Drawer 3", + "Act 1 - Wardrobe Drawer 4", + "Act 1 - Magnificus Eye", + "Act 1 - Painting 1", + "Act 1 - Painting 2", + "Act 1 - Painting 3", + "Act 1 - Greater Smoke" +] + +act2_locations = [ + "Act 2 - Boss Leshy", + "Act 2 - Boss Magnificus", + "Act 2 - Boss Grimora", + "Act 2 - Boss P03", + "Act 2 - Battle Prospector", + "Act 2 - Battle Angler", + "Act 2 - Battle Trapper", + "Act 2 - Battle Sawyer", + "Act 2 - Battle Royal", + "Act 2 - Battle Kaycee", + "Act 2 - Battle Goobert", + "Act 2 - Battle Pike Mage", + "Act 2 - Battle Lonely Wizard", + "Act 2 - Battle Inspector", + "Act 2 - Battle Melter", + "Act 2 - Battle Dredger", + "Act 2 - Dock Chest", + "Act 2 - Forest Cabin Chest", + "Act 2 - Forest Meadow Chest", + "Act 2 - Cabin Wardrobe Drawer", + "Act 2 - Cabin Safe", + "Act 2 - Crypt Casket 1", + "Act 2 - Crypt Casket 2", + "Act 2 - Crypt Well", + "Act 2 - Tower Chest 1", + "Act 2 - Tower Chest 2", + "Act 2 - Tower Chest 3", + "Act 2 - Tentacle", + "Act 2 - Factory Trash Can", + "Act 2 - Factory Drawer 1", + "Act 2 - Factory Drawer 2", + "Act 2 - Factory Chest 1", + "Act 2 - Factory Chest 2", + "Act 2 - Factory Chest 3", + "Act 2 - Factory Chest 4", + "Act 2 - Ancient Obol", + "Act 2 - Bone Lord Femur", + "Act 2 - Bone Lord Horn", + "Act 2 - Bone Lord Holo Key", + "Act 2 - Mycologists Holo Key", + "Act 2 - Camera Replica", + "Act 2 - Clover", + "Act 2 - Monocle", + "Act 2 - Epitaph Piece 1", + "Act 2 - Epitaph Piece 2", + "Act 2 - Epitaph Piece 3", + "Act 2 - Epitaph Piece 4", + "Act 2 - Epitaph Piece 5", + "Act 2 - Epitaph Piece 6", + "Act 2 - Epitaph Piece 7", + "Act 2 - Epitaph Piece 8", + "Act 2 - Epitaph Piece 9" +] + +act3_locations = [ + "Act 3 - Boss Photographer", + "Act 3 - Boss Archivist", + "Act 3 - Boss Unfinished", + "Act 3 - Boss G0lly", + "Act 3 - Boss Mycologists", + "Act 3 - Bone Lord Room", + "Act 3 - Shop Holo Pelt", + "Act 3 - Middle Holo Pelt", + "Act 3 - Forest Holo Pelt", + "Act 3 - Crypt Holo Pelt", + "Act 3 - Tower Holo Pelt", + "Act 3 - Trader 1", + "Act 3 - Trader 2", + "Act 3 - Trader 3", + "Act 3 - Trader 4", + "Act 3 - Trader 5", + "Act 3 - Drawer 1", + "Act 3 - Drawer 2", + "Act 3 - Clock", + "Act 3 - Extra Battery", + "Act 3 - Nano Armor Generator", + "Act 3 - Chest", + "Act 3 - Goobert's Painting", + "Act 3 - Luke's File Entry 1", + "Act 3 - Luke's File Entry 2", + "Act 3 - Luke's File Entry 3", + "Act 3 - Luke's File Entry 4", + "Act 3 - Inspectometer Battery", + "Act 3 - Gems Drone", + "Act 3 - The Great Transcendence", + "Act 3 - Well" +] + +regions_to_locations: Dict[str, List[str]] = { + "Menu": [], + "Act 1": act1_locations, + "Act 2": act2_locations, + "Act 3": act3_locations, + "Epilogue": [] +} diff --git a/worlds/inscryption/Options.py b/worlds/inscryption/Options.py new file mode 100644 index 000000000000..01e9dfb964a4 --- /dev/null +++ b/worlds/inscryption/Options.py @@ -0,0 +1,137 @@ +from dataclasses import dataclass + +from Options import Toggle, Choice, DeathLinkMixin, StartInventoryPool, PerGameCommonOptions, DefaultOnToggle + + +class Act1DeathLinkBehaviour(Choice): + """If DeathLink is enabled, determines what counts as a death in act 1. This affects deaths sent and received. + + - Sacrificed: Send a death when sacrificed by Leshy. Receiving a death will extinguish all candles. + + - Candle Extinguished: Send a death when a candle is extinguished. Receiving a death will extinguish a candle.""" + display_name = "Act 1 Death Link Behaviour" + option_sacrificed = 0 + option_candle_extinguished = 1 + default = 0 + + +class Goal(Choice): + """Defines the goal to accomplish in order to complete the randomizer. + + - Full Story In Order: Complete each act in order. You can return to previously completed acts. + + - Full Story Any Order: Complete each act in any order. All acts are available from the start. + + - First Act: Complete Act 1 by finding the New Game button. Great for a smaller scale randomizer.""" + display_name = "Goal" + option_full_story_in_order = 0 + option_full_story_any_order = 1 + option_first_act = 2 + default = 0 + + +class RandomizeCodes(Toggle): + """Randomize codes and passwords in the game (clocks, safes, etc.)""" + display_name = "Randomize Codes" + + +class RandomizeDeck(Choice): + """Randomize cards in your deck into new cards. + Disable: Disable the feature. + + - Every Encounter Within Same Type: Randomize cards within the same type every encounter (keep rarity/scrybe type). + + - Every Encounter Any Type: Randomize cards into any possible card every encounter. + + - Starting Only: Only randomize cards given at the beginning of runs and acts.""" + display_name = "Randomize Deck" + option_disable = 0 + option_every_encounter_within_same_type = 1 + option_every_encounter_any_type = 2 + option_starting_only = 3 + default = 0 + + +class RandomizeSigils(Choice): + """Randomize sigils printed on the cards into new sigils every encounter. + + - Disable: Disable the feature. + + - Randomize Addons: Only randomize sigils added from sacrifices or other means. + + - Randomize All: Randomize all sigils.""" + display_name = "Randomize Abilities" + option_disable = 0 + option_randomize_addons = 1 + option_randomize_all = 2 + default = 0 + + +class OptionalDeathCard(Choice): + """Add a moment after death in act 1 where you can decide to create a death card or not. + + - Disable: Disable the feature. + + - Always On: The choice is always offered after losing all candles. + + - DeathLink Only: The choice is only offered after receiving a DeathLink event.""" + display_name = "Optional Death Card" + option_disable = 0 + option_always_on = 1 + option_deathlink_only = 2 + default = 2 + + +class SkipTutorial(DefaultOnToggle): + """Skips the first few tutorial runs of act 1. Bones are available from the start.""" + display_name = "Skip Tutorial" + + +class SkipEpilogue(Toggle): + """Completes the goal as soon as the required acts are completed without the need of completing the epilogue.""" + display_name = "Skip Epilogue" + + +class EpitaphPiecesRandomization(Choice): + """Determines how epitaph pieces in act 2 are randomized. This can affect your chances of getting stuck. + + - All Pieces: Randomizes all nine pieces as their own item. + + - In Groups: Randomizes pieces in groups of three. + + - As One Item: Group all nine pieces as a single item.""" + display_name = "Epitaph Pieces Randomization" + option_all_pieces = 0 + option_in_groups = 1 + option_as_one_item = 2 + default = 0 + + +class PaintingChecksBalancing(Choice): + """Generation options for the second and third painting checks in act 1. + + - None: Adds no progression logic to these painting checks. They will all count as sphere 1 (early game checks). + + - Balanced: Adds rules to these painting checks. Early game items are less likely to appear into these paintings. + + - Force Filler: For when you dislike doing these last two paintings. Their checks will only contain filler items.""" + display_name = "Painting Checks Balancing" + option_none = 0 + option_balanced = 1 + option_force_filler = 2 + default = 1 + + +@dataclass +class InscryptionOptions(DeathLinkMixin, PerGameCommonOptions): + start_inventory_from_pool: StartInventoryPool + act1_death_link_behaviour: Act1DeathLinkBehaviour + goal: Goal + randomize_codes: RandomizeCodes + randomize_deck: RandomizeDeck + randomize_sigils: RandomizeSigils + optional_death_card: OptionalDeathCard + skip_tutorial: SkipTutorial + skip_epilogue: SkipEpilogue + epitaph_pieces_randomization: EpitaphPiecesRandomization + painting_checks_balancing: PaintingChecksBalancing diff --git a/worlds/inscryption/Regions.py b/worlds/inscryption/Regions.py new file mode 100644 index 000000000000..357261da7579 --- /dev/null +++ b/worlds/inscryption/Regions.py @@ -0,0 +1,14 @@ +from typing import Dict, List + +inscryption_regions_all: Dict[str, List[str]] = { + "Menu": ["Act 1", "Act 2", "Act 3", "Epilogue"], + "Act 1": [], + "Act 2": [], + "Act 3": [], + "Epilogue": [] +} + +inscryption_regions_act_1: Dict[str, List[str]] = { + "Menu": ["Act 1"], + "Act 1": [] +} diff --git a/worlds/inscryption/Rules.py b/worlds/inscryption/Rules.py new file mode 100644 index 000000000000..d9791ce94c7a --- /dev/null +++ b/worlds/inscryption/Rules.py @@ -0,0 +1,181 @@ +from typing import Dict, Callable, TYPE_CHECKING +from BaseClasses import CollectionState, LocationProgressType +from .Options import Goal, PaintingChecksBalancing + +if TYPE_CHECKING: + from . import InscryptionWorld +else: + InscryptionWorld = object + + +# Based on The Messenger's implementation +class InscryptionRules: + player: int + world: InscryptionWorld + location_rules: Dict[str, Callable[[CollectionState], bool]] + region_rules: Dict[str, Callable[[CollectionState], bool]] + + def __init__(self, world: InscryptionWorld) -> None: + self.player = world.player + self.world = world + self.location_rules = { + "Act 1 - Wardrobe Drawer 1": self.has_wardrobe_key, + "Act 1 - Wardrobe Drawer 2": self.has_wardrobe_key, + "Act 1 - Wardrobe Drawer 3": self.has_wardrobe_key, + "Act 1 - Wardrobe Drawer 4": self.has_wardrobe_key, + "Act 1 - Dagger": self.has_caged_wolf, + "Act 1 - Magnificus Eye": self.has_dagger, + "Act 1 - Clock Main Compartment": self.has_magnificus_eye, + "Act 2 - Battle Prospector": self.has_camera_and_meat, + "Act 2 - Battle Angler": self.has_camera_and_meat, + "Act 2 - Battle Trapper": self.has_camera_and_meat, + "Act 2 - Battle Pike Mage": self.has_tower_requirements, + "Act 2 - Battle Goobert": self.has_tower_requirements, + "Act 2 - Battle Lonely Wizard": self.has_tower_requirements, + "Act 2 - Battle Inspector": self.has_act2_bridge_requirements, + "Act 2 - Battle Melter": self.has_act2_bridge_requirements, + "Act 2 - Battle Dredger": self.has_act2_bridge_requirements, + "Act 2 - Forest Meadow Chest": self.has_camera_and_meat, + "Act 2 - Tower Chest 1": self.has_act2_bridge_requirements, + "Act 2 - Tower Chest 2": self.has_tower_requirements, + "Act 2 - Tower Chest 3": self.has_tower_requirements, + "Act 2 - Tentacle": self.has_tower_requirements, + "Act 2 - Factory Trash Can": self.has_act2_bridge_requirements, + "Act 2 - Factory Drawer 1": self.has_act2_bridge_requirements, + "Act 2 - Factory Drawer 2": self.has_act2_bridge_requirements, + "Act 2 - Factory Chest 1": self.has_act2_bridge_requirements, + "Act 2 - Factory Chest 2": self.has_act2_bridge_requirements, + "Act 2 - Factory Chest 3": self.has_act2_bridge_requirements, + "Act 2 - Factory Chest 4": self.has_act2_bridge_requirements, + "Act 2 - Monocle": self.has_act2_bridge_requirements, + "Act 2 - Boss Grimora": self.has_all_epitaph_pieces, + "Act 2 - Boss Leshy": self.has_camera_and_meat, + "Act 2 - Boss Magnificus": self.has_tower_requirements, + "Act 2 - Boss P03": self.has_act2_bridge_requirements, + "Act 2 - Bone Lord Femur": self.has_obol, + "Act 2 - Bone Lord Horn": self.has_obol, + "Act 2 - Bone Lord Holo Key": self.has_obol, + "Act 2 - Mycologists Holo Key": self.has_tower_requirements, # Could need money + "Act 2 - Ancient Obol": self.has_tower_requirements, # Need money for the pieces? Use the tower mannequin. + "Act 3 - Boss Photographer": self.has_inspectometer_battery, + "Act 3 - Boss Archivist": self.has_battery_and_quill, + "Act 3 - Boss Unfinished": self.has_gems_and_battery, + "Act 3 - Boss G0lly": self.has_gems_and_battery, + "Act 3 - Extra Battery": self.has_inspectometer_battery, # Hard to miss but soft lock still possible. + "Act 3 - Nano Armor Generator": self.has_gems_and_battery, # Costs money, so can need multiple battles. + "Act 3 - Shop Holo Pelt": self.has_gems_and_battery, # Costs money, so can need multiple battles. + "Act 3 - Middle Holo Pelt": self.has_inspectometer_battery, # Can be reached without but possible soft lock + "Act 3 - Forest Holo Pelt": self.has_inspectometer_battery, + "Act 3 - Crypt Holo Pelt": self.has_inspectometer_battery, + "Act 3 - Tower Holo Pelt": self.has_gems_and_battery, + "Act 3 - Trader 1": self.has_pelts(1), + "Act 3 - Trader 2": self.has_pelts(2), + "Act 3 - Trader 3": self.has_pelts(3), + "Act 3 - Trader 4": self.has_pelts(4), + "Act 3 - Trader 5": self.has_pelts(5), + "Act 3 - Goobert's Painting": self.has_gems_and_battery, + "Act 3 - The Great Transcendence": self.has_transcendence_requirements, + "Act 3 - Boss Mycologists": self.has_mycologists_boss_requirements, + "Act 3 - Bone Lord Room": self.has_bone_lord_room_requirements, + "Act 3 - Luke's File Entry 1": self.has_battery_and_quill, + "Act 3 - Luke's File Entry 2": self.has_battery_and_quill, + "Act 3 - Luke's File Entry 3": self.has_battery_and_quill, + "Act 3 - Luke's File Entry 4": self.has_transcendence_requirements, + "Act 3 - Well": self.has_inspectometer_battery, + "Act 3 - Gems Drone": self.has_inspectometer_battery, + "Act 3 - Clock": self.has_gems_and_battery, # Can be brute-forced, but the solution needs those items. + } + self.region_rules = { + "Act 2": self.has_act2_requirements, + "Act 3": self.has_act3_requirements, + "Epilogue": self.has_epilogue_requirements + } + + def has_wardrobe_key(self, state: CollectionState) -> bool: + return state.has("Wardrobe Key", self.player) + + def has_caged_wolf(self, state: CollectionState) -> bool: + return state.has("Caged Wolf Card", self.player) + + def has_dagger(self, state: CollectionState) -> bool: + return state.has("Dagger", self.player) + + def has_magnificus_eye(self, state: CollectionState) -> bool: + return state.has("Magnificus Eye", self.player) + + def has_useful_act1_items(self, state: CollectionState) -> bool: + return state.has_all(("Oil Painting's Clover Plant", "Squirrel Totem Head"), self.player) + + def has_all_epitaph_pieces(self, state: CollectionState) -> bool: + return state.has(self.world.required_epitaph_pieces_name, self.player, self.world.required_epitaph_pieces_count) + + def has_camera_and_meat(self, state: CollectionState) -> bool: + return state.has_all(("Camera Replica", "Pile Of Meat"), self.player) + + def has_monocle(self, state: CollectionState) -> bool: + return state.has("Monocle", self.player) + + def has_obol(self, state: CollectionState) -> bool: + return state.has("Ancient Obol", self.player) + + def has_epitaphs_and_forest_items(self, state: CollectionState) -> bool: + return self.has_camera_and_meat(state) and self.has_all_epitaph_pieces(state) + + def has_act2_bridge_requirements(self, state: CollectionState) -> bool: + return self.has_camera_and_meat(state) or self.has_all_epitaph_pieces(state) + + def has_tower_requirements(self, state: CollectionState) -> bool: + return self.has_monocle(state) and self.has_act2_bridge_requirements(state) + + def has_inspectometer_battery(self, state: CollectionState) -> bool: + return state.has("Inspectometer Battery", self.player) + + def has_gems_and_battery(self, state: CollectionState) -> bool: + return state.has("Gems Module", self.player) and self.has_inspectometer_battery(state) + + def has_pelts(self, count: int) -> Callable[[CollectionState], bool]: + return lambda state: state.has("Holo Pelt", self.player, count) and self.has_gems_and_battery(state) + + def has_mycologists_boss_requirements(self, state: CollectionState) -> bool: + return state.has("Mycologists Holo Key", self.player) and self.has_transcendence_requirements(state) + + def has_bone_lord_room_requirements(self, state: CollectionState) -> bool: + return state.has("Bone Lord Holo Key", self.player) and self.has_inspectometer_battery(state) + + def has_battery_and_quill(self, state: CollectionState) -> bool: + return state.has("Quill", self.player) and self.has_inspectometer_battery(state) + + def has_transcendence_requirements(self, state: CollectionState) -> bool: + return state.has("Quill", self.player) and self.has_gems_and_battery(state) + + def has_act2_requirements(self, state: CollectionState) -> bool: + return state.has("Film Roll", self.player) + + def has_act3_requirements(self, state: CollectionState) -> bool: + return self.has_act2_requirements(state) and self.has_all_epitaph_pieces(state) and \ + self.has_camera_and_meat(state) and self.has_monocle(state) + + def has_epilogue_requirements(self, state: CollectionState) -> bool: + return self.has_act3_requirements(state) and self.has_transcendence_requirements(state) + + def set_all_rules(self) -> None: + multiworld = self.world.multiworld + if self.world.options.goal != Goal.option_first_act: + multiworld.completion_condition[self.player] = self.has_epilogue_requirements + else: + multiworld.completion_condition[self.player] = self.has_act2_requirements + for region in multiworld.get_regions(self.player): + if self.world.options.goal == Goal.option_full_story_in_order: + if region.name in self.region_rules: + for entrance in region.entrances: + entrance.access_rule = self.region_rules[region.name] + for loc in region.locations: + if loc.name in self.location_rules: + loc.access_rule = self.location_rules[loc.name] + + if self.world.options.painting_checks_balancing == PaintingChecksBalancing.option_balanced: + self.world.get_location("Act 1 - Painting 2").access_rule = self.has_useful_act1_items + self.world.get_location("Act 1 - Painting 3").access_rule = self.has_useful_act1_items + elif self.world.options.painting_checks_balancing == PaintingChecksBalancing.option_force_filler: + self.world.get_location("Act 1 - Painting 2").progress_type = LocationProgressType.EXCLUDED + self.world.get_location("Act 1 - Painting 3").progress_type = LocationProgressType.EXCLUDED diff --git a/worlds/inscryption/__init__.py b/worlds/inscryption/__init__.py new file mode 100644 index 000000000000..d84912e1ca0b --- /dev/null +++ b/worlds/inscryption/__init__.py @@ -0,0 +1,144 @@ +from .Options import InscryptionOptions, Goal, EpitaphPiecesRandomization, PaintingChecksBalancing +from .Items import act1_items, act2_items, act3_items, filler_items, base_id, InscryptionItem, ItemDict +from .Locations import act1_locations, act2_locations, act3_locations, regions_to_locations +from .Regions import inscryption_regions_all, inscryption_regions_act_1 +from typing import Dict, Any +from . import Rules +from BaseClasses import Region, Item, Tutorial, ItemClassification +from worlds.AutoWorld import World, WebWorld + + +class InscrypWeb(WebWorld): + theme = "dirt" + + guide_en = Tutorial( + "Multiworld Setup Guide", + "A guide to setting up the Inscryption Archipelago Multiworld", + "English", + "setup_en.md", + "setup/en", + ["DrBibop"] + ) + + guide_fr = Tutorial( + "Multiworld Setup Guide", + "Un guide pour configurer Inscryption Archipelago Multiworld", + "Français", + "setup_fr.md", + "setup/fr", + ["Glowbuzz"] + ) + + tutorials = [guide_en, guide_fr] + + bug_report_page = "https://github.com/DrBibop/Archipelago_Inscryption/issues" + + +class InscryptionWorld(World): + """ + Inscryption is an inky black card-based odyssey that blends the deckbuilding roguelike, + escape-room style puzzles, and psychological horror into a blood-laced smoothie. + Darker still are the secrets inscrybed upon the cards... + """ + game = "Inscryption" + web = InscrypWeb() + options_dataclass = InscryptionOptions + options: InscryptionOptions + all_items = act1_items + act2_items + act3_items + filler_items + item_name_to_id = {item["name"]: i + base_id for i, item in enumerate(all_items)} + all_locations = act1_locations + act2_locations + act3_locations + location_name_to_id = {location: i + base_id for i, location in enumerate(all_locations)} + required_epitaph_pieces_count = 9 + required_epitaph_pieces_name = "Epitaph Piece" + + def generate_early(self) -> None: + self.all_items = [item.copy() for item in self.all_items] + + if self.options.epitaph_pieces_randomization == EpitaphPiecesRandomization.option_all_pieces: + self.required_epitaph_pieces_name = "Epitaph Piece" + self.required_epitaph_pieces_count = 9 + elif self.options.epitaph_pieces_randomization == EpitaphPiecesRandomization.option_in_groups: + self.required_epitaph_pieces_name = "Epitaph Pieces" + self.required_epitaph_pieces_count = 3 + else: + self.required_epitaph_pieces_name = "Epitaph Pieces" + self.required_epitaph_pieces_count = 1 + + if self.options.painting_checks_balancing == PaintingChecksBalancing.option_balanced: + self.all_items[6]["classification"] = ItemClassification.progression + self.all_items[11]["classification"] = ItemClassification.progression + + if self.options.painting_checks_balancing == PaintingChecksBalancing.option_force_filler \ + and self.options.goal == Goal.option_first_act: + self.all_items[3]["classification"] = ItemClassification.filler + + if self.options.epitaph_pieces_randomization != EpitaphPiecesRandomization.option_all_pieces: + self.all_items[len(act1_items) + 3]["count"] = self.required_epitaph_pieces_count + + def get_filler_item_name(self) -> str: + return self.random.choice(filler_items)["name"] + + def create_item(self, name: str) -> Item: + item_id = self.item_name_to_id[name] + item_data = self.all_items[item_id - base_id] + return InscryptionItem(name, item_data["classification"], item_id, self.player) + + def create_items(self) -> None: + nb_items_added = 0 + useful_items = self.all_items.copy() + + if self.options.goal != Goal.option_first_act: + useful_items = [item for item in useful_items + if not any(filler_item["name"] == item["name"] for filler_item in filler_items)] + if self.options.epitaph_pieces_randomization == EpitaphPiecesRandomization.option_all_pieces: + useful_items.pop(len(act1_items) + 3) + else: + useful_items.pop(len(act1_items) + 2) + else: + useful_items = [item for item in useful_items + if any(act1_item["name"] == item["name"] for act1_item in act1_items)] + + for item in useful_items: + for _ in range(item["count"]): + new_item = self.create_item(item["name"]) + self.multiworld.itempool.append(new_item) + nb_items_added += 1 + + filler_count = len(self.all_locations if self.options.goal != Goal.option_first_act else act1_locations) + filler_count -= nb_items_added + + for i in range(filler_count): + index = i % len(filler_items) + filler_item = filler_items[index] + new_item = self.create_item(filler_item["name"]) + self.multiworld.itempool.append(new_item) + + def create_regions(self) -> None: + used_regions = inscryption_regions_all if self.options.goal != Goal.option_first_act \ + else inscryption_regions_act_1 + for region_name in used_regions.keys(): + self.multiworld.regions.append(Region(region_name, self.player, self.multiworld)) + + for region_name, region_connections in used_regions.items(): + region = self.get_region(region_name) + region.add_exits(region_connections) + region.add_locations({ + location: self.location_name_to_id[location] for location in regions_to_locations[region_name] + }) + + def set_rules(self) -> None: + Rules.InscryptionRules(self).set_all_rules() + + def fill_slot_data(self) -> Dict[str, Any]: + return self.options.as_dict( + "death_link", + "act1_death_link_behaviour", + "goal", + "randomize_codes", + "randomize_deck", + "randomize_sigils", + "optional_death_card", + "skip_tutorial", + "skip_epilogue", + "epitaph_pieces_randomization" + ) diff --git a/worlds/inscryption/docs/en_Inscryption.md b/worlds/inscryption/docs/en_Inscryption.md new file mode 100644 index 000000000000..da6d7c8dcb0b --- /dev/null +++ b/worlds/inscryption/docs/en_Inscryption.md @@ -0,0 +1,22 @@ +# Inscryption + +## Where is the options page? +You can configure your player options with the Inscryption options page. [Click here](../player-options) to start configuring them to your liking. + +## What does randomization do to this game? +Due to the nature of the randomizer, you are allowed to return to a previous act you've previously completed if there are location checks you've missed. The "New Game" option is replaced with a "Chapter Select" option and is enabled after you beat act 1. If you prefer, you can also make all acts available from the start by changing the goal option. All items that you can find lying around, in containers, or from puzzles are randomized and replaced with location checks. Boss fights from all acts and battles from act 2 also count as location checks. + +## What is the goal of Inscryption when randomized? +By default, the goal is considered reached once you open the OLD_DATA file. This means playing through all three acts in order and the epilogue. You can change the goal option to instead complete all acts in any order or simply complete act 1. + +## Which items can be in another player's world? +All key items necessary for progression such as the film roll, the dagger, Grimora's epitaphs, etc. Unique cards that aren't randomly found in the base game (e.g. talking cards) are also included. For filler items, you can receive currency which will be added to every act's bank or card packs that you can open at any time when inspecting your deck. + +## What does another world's item look like in Inscryption? +Items from other worlds usually take the appearance of a normal card from the current act you're playing. The card's name contains the item that will be sent when picked up and its portrait is the Archipelago logo (a ring of six circles). Picking up these cards does not add them to your deck. + +## When the player receives an item, what happens? +The item is instantly granted to you. A yellow message appears in the Archipelago logs at the top-right of your screen. An audio cue is also played. If the item received is a holdable item (wardrobe key, inspectometer battery, gems module), the item will be placed where you would usually collect it in a vanilla playthrough (safe, inspectometer, drone). + +## How many items can I find or receive in my world? +By default, if all three acts are played, there are **100** randomized locations in your world and **100** of your items shuffled in the multiworld. There are **17** locations in act 1 (this will be the total amount if you decide to only play act 1), **52** locations in act 2, and **31** locations in act 3. diff --git a/worlds/inscryption/docs/setup_en.md b/worlds/inscryption/docs/setup_en.md new file mode 100644 index 000000000000..a57e266c4849 --- /dev/null +++ b/worlds/inscryption/docs/setup_en.md @@ -0,0 +1,65 @@ +# Inscryption Randomizer Setup Guide + +## Required Software + +- [Inscryption](https://store.steampowered.com/app/1092790/Inscryption/) +- For easy setup (recommended): + - [r2modman](https://inscryption.thunderstore.io/package/ebkr/r2modman/) OR [Thunderstore Mod Manager](https://www.overwolf.com/app/Thunderstore-Thunderstore_Mod_Manager) +- For manual setup: + - [BepInEx pack for Inscryption](https://inscryption.thunderstore.io/package/BepInEx/BepInExPack_Inscryption/) + - [ArchipelagoMod](https://inscryption.thunderstore.io/package/Ballin_Inc/ArchipelagoMod/) + +## Installation +Before starting the installation process, here's what you should know: +- Only install the mods mentioned in this guide if you want a guaranteed smooth experience! Other mods were NOT tested with ArchipelagoMod and could cause unwanted issues. +- The ArchipelagoMod uses its own save file system when playing, but for safety measures, back up your save file by going to your Inscryption installation directory and copy the `SaveFile.gwsave` file to another folder. +- It is strongly recommended to use a mod manager if you want a quicker and easier installation process, but if you don't like installing extra software and are comfortable moving files around, you can refer to the manual setup guide instead. + +### Easy setup (mod manager) +1. Download [r2modman](https://inscryption.thunderstore.io/package/ebkr/r2modman/) using the "Manual Download" button, then install it using the executable in the downloaded zip package (You can also use [Thunderstore Mod Manager](https://www.overwolf.com/app/Thunderstore-Thunderstore_Mod_Manager) which works the same, but it requires [Overwolf](https://www.overwolf.com/)) +2. Open the mod manager and select Inscryption in the game selection screen. +3. Select the default profile or create a new one. +4. Open the `Online` tab on the left, then search for `ArchipelagoMod`. +5. Expand ArchipelagoMod and click the `Download` button to install the latest version and all its dependencies. +6. Click `Start Modded` to open the game with the mods (a console should appear if everything was done correctly). + +### Manual setup +1. Download the following mods using the `Manual Download` button: + - [BepInEx pack for Inscryption](https://inscryption.thunderstore.io/package/BepInEx/BepInExPack_Inscryption/) + - [ArchipelagoMod](https://inscryption.thunderstore.io/package/Ballin_Inc/ArchipelagoMod/) +2. Open your Inscryption installation directory. On Steam, you can find it easily by right-clicking the game and clicking `Manage` > `Browse local files`. +3. Open the BepInEx pack zip file, then open the `BepInExPack_Inscryption` folder. +4. Drag all folders and files located inside the `BepInExPack_Inscryption` folder and drop them in your Inscryption directory. +5. Open the `BepInEx` folder in your Inscryption directory. +6. Open the ArchipelagoMod zip file. +7. Drag and drop the `plugins` folder in the `BepInEx` folder to fuse with the existing `plugins` folder. +8. Open the game normally to play with mods (if BepInEx was installed correctly, a console should appear). + +## Joining a new MultiWorld Game +1. After opening the game, you should see a new menu for browsing and creating save files. +2. Click on the `New Game` button, then write a unique name for your save file. +3. On the next screen, enter the information needed to connect to the MultiWorld server, then press the `Connect` button. +4. If successful, the status on the top-right will change to "Connected". If not, a red error message will appear. +5. After connecting to the server and receiving items, the game menu will appear. + +## Continuing a MultiWorld Game +1. After opening the game, you should see a list of your save files and a button to add a new one. +2. Find the save file you want to use, then click its `Play` button. +3. On the next screen, the input fields will be filled with the information you've written previously. You can adjust some fields if needed, then press the `Connect` button. +4. If successful, the status on the top-right will change to "connected". If not, a red error message will appear. +5. After connecting to the server and receiving items, the game menu will appear. + +## Troubleshooting +### The game opens normally without the new menu. +If the new menu mentioned previously doesn't appear, it can be one of two issues: + - If there was no console appearing when opening the game, this means the mods didn't load correctly. Here's what you can try: + - If you are using the mod manager, make sure to open it and press `Start Modded`. Opening the game normally from Steam won't load any mods. + - Check if the mod manager correctly found the game path. In the mod manager, click `Settings` then go to the `Locations` tab. Make sure the path listed under `Change Inscryption directory` is correct. You can verify the real path if you right-click the game on steam and click `Manage` > `Browse local files`. If the path is wrong, click that setting and change the path. + - If you installed the mods manually, this usually means BepInEx was not correctly installed. Make sure to read the installation guide carefully. + - If there is still no console when opening the game modded, try asking in the [Archipelago Discord Server](https://discord.gg/8Z65BR2) for help. + - If there is a console, this means the mods loaded but the ArchipelagoMod wasn't found or had errors while loading. + - Look in the console and make sure you can find a message about ArchipelagoMod being loaded. + - If you see any red text, there was an error. Report the issue in the [Archipelago Discord Server](https://discord.gg/8Z65BR2) or create an issue in our [GitHub](https://github.com/DrBibop/Archipelago_Inscryption/issues). + +### I'm getting a different issue. +You can ask for help in the [Archipelago Discord Server](https://discord.gg/8Z65BR2) or, if you think you've found a bug with the mod, create an issue in our [GitHub](https://github.com/DrBibop/Archipelago_Inscryption/issues). \ No newline at end of file diff --git a/worlds/inscryption/docs/setup_fr.md b/worlds/inscryption/docs/setup_fr.md new file mode 100644 index 000000000000..21d0617cbac4 --- /dev/null +++ b/worlds/inscryption/docs/setup_fr.md @@ -0,0 +1,67 @@ +# Guide d'Installation de Inscryption Randomizer + +## Logiciel Exigé + +- [Inscryption](https://store.steampowered.com/app/1092790/Inscryption/) +- Pour une installation facile (recommandé): + - [r2modman](https://inscryption.thunderstore.io/package/ebkr/r2modman/) OU [Thunderstore Mod Manager](https://www.overwolf.com/app/Thunderstore-Thunderstore_Mod_Manager) +- Pour une installation manuelle: + - [BepInEx pack for Inscryption](https://inscryption.thunderstore.io/package/BepInEx/BepInExPack_Inscryption/) + - [MonoMod Loader for Inscryption](https://inscryption.thunderstore.io/package/BepInEx/MonoMod_Loader_Inscryption/) + - [Inscryption API](https://inscryption.thunderstore.io/package/API_dev/API/) + - [ArchipelagoMod](https://inscryption.thunderstore.io/package/Ballin_Inc/ArchipelagoMod/) + +## Installation +Avant de commencer le processus d'installation, voici ce que vous deviez savoir: +- Installez uniquement les mods mentionnés dans ce guide si vous souhaitez une expérience stable! Les autres mods n'ont PAS été testés avec ArchipelagoMod et peuvent provoquer des problèmes. +- ArchipelagoMod utilise son propre système de sauvegarde lorsque vous jouez, mais pour des raisons de sécurité, sauvegardez votre fichier de sauvegarde en accédant à votre répertoire d'installation Inscryption et copiez le fichier `SaveFile.gwsave` dans un autre dossier. +- Il est fortement recommandé d'utiliser un mod manager si vous souhaitez avoir un processus d'installation plus rapide et plus facile, mais si vous n'aimez pas installer de logiciels supplémentaires et que vous êtes à l'aise pour déplacer des fichiers, vous pouvez vous référer au guide de configuration manuelle. + +### Installation facile (mod manager) +1. Téléchargez [r2modman](https://inscryption.thunderstore.io/package/ebkr/r2modman/) à l'aide du bouton `Manual Download`, puis installez-le à l'aide de l'exécutable contenu dans le zip téléchargé (vous pouvez également utiliser [Thunderstore Mod Manager](https://www.overwolf.com/app/Thunderstore-Thunderstore_Mod_Manager) qui fonctionne de la même manière, mais cela nécessite [Overwolf](https://www.overwolf.com/)) +2. Ouvrez le mod manager et sélectionnez Inscryption dans l'écran de sélection de jeu. +3. Sélectionnez le profil par défaut ou créez-en un nouveau. +4. Ouvrez l'onglet `Online` à gauche, puis recherchez `ArchipelagoMod`. +5. Développez ArchipelagoMod et cliquez sur le bouton `Download` pour installer la dernière version disponible et toutes ses dépendances. +6. Cliquez sur `Start Modded` pour ouvrir le jeu avec les mods (une console devrait apparaître si tout a été fait correctement). + +### Installation manuelle +1. Téléchargez les mods suivants en utilisant le bouton `Manual Download`: + - [BepInEx pack for Inscryption](https://inscryption.thunderstore.io/package/BepInEx/BepInExPack_Inscryption/) + - [ArchipelagoMod](https://inscryption.thunderstore.io/package/Ballin_Inc/ArchipelagoMod/) +2. Ouvrez votre dossier d'installation d'Inscryption. Sur Steam, vous pouvez le trouver facilement en faisant un clic droit sur le jeu et en cliquant sur `Gérer` > `Parcourir les fichiers locaux`. +3. Ouvrez le fichier zip du pack BepInEx, puis ouvrez le dossier `BepInExPack_Inscryption`. +4. Prenez tous les dossiers et fichiers situés dans le dossier `BepInExPack_Inscryption` et déposez-les dans votre dossier Inscryption. +5. Ouvrez le dossier `BepInEx` dans votre dossier Inscryption. +6. Ouvrez le fichier zip d'ArchipelagoMod. +7. Prenez et déposez le dossier `plugins` dans le dossier `BepInEx` pour fusionner avec le dossier `plugins` existant. +8. Ouvrez le jeu normalement pour jouer avec les mods (si BepInEx a été correctement installé, une console devrait apparaitre). + +## Rejoindre un nouveau MultiWorld +1. Après avoir ouvert le jeu, vous devriez voir un nouveau menu pour parcourir et créer des fichiers de sauvegarde. +2. Cliquez sur le bouton `New Game`, puis écrivez un nom unique pour votre fichier de sauvegarde. +3. Sur l'écran suivant, saisissez les informations nécessaires pour vous connecter au serveur MultiWorld, puis appuyez sur le bouton `Connect`. +4. En cas de succès, l'état de connexion en haut à droite changera pour "Connected". Sinon, un message d'erreur rouge apparaîtra. +5. Après s'être connecté au server et avoir reçu les items, le menu du jeu apparaîtra. + +## Poursuivre une session MultiWorld +1. Après avoir ouvert le jeu, vous devriez voir une liste de vos fichiers de sauvegarde et un bouton pour en ajouter un nouveau. +2. Choisissez le fichier de sauvegarde que vous souhaitez utiliser, puis cliquez sur son bouton `Play`. +3. Sur l'écran suivant, les champs de texte seront remplis avec les informations que vous avez écrites précédemment. Vous pouvez ajuster certains champs si nécessaire, puis appuyer sur le bouton `Connect`. +4. En cas de succès, l'état de connexion en haut à droite changera pour "Connected". Sinon, un message d'erreur rouge apparaîtra. +5. Après s'être connecté au server et avoir reçu les items, le menu du jeu apparaîtra. + +## Dépannage +### Le jeu ouvre normalement sans nouveau menu. +Si le nouveau menu mentionné précédemment n'apparaît pas, c'est peut-être l'un des deux problèmes suivants: + - Si aucune console n'apparait à l'ouverture du jeu, cela signifie que les mods ne se sont pas chargés correctement. Voici ce que vous pouvez essayer: + - Si vous utilisez le mod manager, assurez-vous de l'ouvrir et d'appuyer sur `Start Modded`. Ouvrir le jeu normalement depuis Steam ne chargera aucun mod. + - Vérifiez si le mod manager a correctement trouvé le répertoire du jeu. Dans le mod manager, cliquez sur `Settings` puis allez dans l'onglet `Locations`. Assurez-vous que le répertoire sous `Change Inscryption directory` est correct. Vous pouvez vérifier le répertoire correct si vous faites un clic droit sur le jeu Inscription sur Steam et cliquez sur `Gérer` > `Parcourir les fichiers locaux`. Si le répertoire est erroné, cliquez sur ce paramètre et modifiez le répertoire. + - Si vous avez installé les mods manuellement, cela signifie généralement que BepInEx n'a pas été correctement installé. Assurez-vous de lire attentivement le guide d'installation. + - S'il n'y a toujours pas de console lors de l'ouverture du jeu modifié, essayez de demander de l'aide sur [Archipelago Discord Server](https://discord.gg/8Z65BR2). + - S'il y a une console, cela signifie que les mods ont été chargés, mais que ArchipelagoMod n'a pas été trouvé ou a eu des erreurs lors du chargement. + - Regardez dans la console et assurez-vous que vous trouvez un message concernant le chargement d'ArchipelagoMod. + - Si vous voyez du texte rouge, il y a eu une erreur. Signalez le problème dans [Archipelago Discord Server](https://discord.gg/8Z65BR2) ou dans notre [GitHub](https://github.com/DrBibop/Archipelago_Inscryption/issues). + +### J'ai un autre problème. +Vous pouvez demander de l'aide sur [le serveur Discord d'Archipelago](https://discord.gg/8Z65BR2) ou, si vous pensez avoir trouvé un bug avec le mod, signalez-le dans notre [GitHub](https://github.com/DrBibop/Archipelago_Inscryption/issues). \ No newline at end of file diff --git a/worlds/inscryption/test/TestAccess.py b/worlds/inscryption/test/TestAccess.py new file mode 100644 index 000000000000..eeafc933bbbc --- /dev/null +++ b/worlds/inscryption/test/TestAccess.py @@ -0,0 +1,221 @@ +from . import InscryptionTestBase + + +class AccessTestGeneral(InscryptionTestBase): + + def test_dagger(self) -> None: + self.assertAccessDependency(["Act 1 - Magnificus Eye"], [["Dagger"]]) + + def test_caged_wolf(self) -> None: + self.assertAccessDependency(["Act 1 - Dagger"], [["Caged Wolf Card"]]) + + def test_magnificus_eye(self) -> None: + self.assertAccessDependency(["Act 1 - Clock Main Compartment"], [["Magnificus Eye"]]) + + def test_wardrobe_key(self) -> None: + self.assertAccessDependency( + ["Act 1 - Wardrobe Drawer 1", "Act 1 - Wardrobe Drawer 2", + "Act 1 - Wardrobe Drawer 3", "Act 1 - Wardrobe Drawer 4"], + [["Wardrobe Key"]] + ) + + def test_ancient_obol(self) -> None: + self.assertAccessDependency( + ["Act 2 - Bone Lord Femur", "Act 2 - Bone Lord Horn", "Act 2 - Bone Lord Holo Key"], + [["Ancient Obol"]] + ) + + def test_holo_pelt(self) -> None: + self.assertAccessDependency( + ["Act 3 - Trader 1", "Act 3 - Trader 2", "Act 3 - Trader 3", "Act 3 - Trader 4", "Act 3 - Trader 5"], + [["Holo Pelt"]] + ) + + def test_inspectometer_battery(self) -> None: + self.assertAccessDependency( + ["Act 3 - Boss Photographer", "Act 3 - Boss Archivist", "Act 3 - Boss Unfinished", "Act 3 - Boss G0lly", + "Act 3 - Trader 1", "Act 3 - Trader 2", "Act 3 - Trader 3", "Act 3 - Trader 4", "Act 3 - Trader 5", + "Act 3 - Shop Holo Pelt", "Act 3 - Middle Holo Pelt", "Act 3 - Forest Holo Pelt", "Act 3 - Clock", + "Act 3 - Crypt Holo Pelt", "Act 3 - Gems Drone", "Act 3 - Nano Armor Generator", "Act 3 - Extra Battery", + "Act 3 - Tower Holo Pelt", "Act 3 - The Great Transcendence", "Act 3 - Boss Mycologists", + "Act 3 - Bone Lord Room", "Act 3 - Well", "Act 3 - Luke's File Entry 1", "Act 3 - Luke's File Entry 2", + "Act 3 - Luke's File Entry 3", "Act 3 - Luke's File Entry 4", "Act 3 - Goobert's Painting"], + [["Inspectometer Battery"]] + ) + + def test_gem_drone(self) -> None: + self.assertAccessDependency( + ["Act 3 - Boss Unfinished", "Act 3 - Boss G0lly", "Act 3 - Trader 1", "Act 3 - Trader 2", + "Act 3 - Trader 3", "Act 3 - Trader 4", "Act 3 - Trader 5", "Act 3 - Shop Holo Pelt", "Act 3 - Clock", + "Act 3 - Tower Holo Pelt", "Act 3 - The Great Transcendence", "Act 3 - Luke's File Entry 4", + "Act 3 - Boss Mycologists", "Act 3 - Nano Armor Generator", "Act 3 - Goobert's Painting"], + [["Gems Module"]] + ) + + def test_mycologists_holo_key(self) -> None: + self.assertAccessDependency( + ["Act 3 - Boss Mycologists"], + [["Mycologists Holo Key"]] + ) + + def test_bone_lord_holo_key(self) -> None: + self.assertAccessDependency( + ["Act 3 - Bone Lord Room"], + [["Bone Lord Holo Key"]] + ) + + def test_quill(self) -> None: + self.assertAccessDependency( + ["Act 3 - Boss Archivist", "Act 3 - Luke's File Entry 1", "Act 3 - Luke's File Entry 2", + "Act 3 - Luke's File Entry 3", "Act 3 - Luke's File Entry 4", "Act 3 - The Great Transcendence", + "Act 3 - Boss Mycologists"], + [["Quill"]] + ) + + +class AccessTestOrdered(InscryptionTestBase): + options = { + "goal": 0, + } + + def test_film_roll(self) -> None: + self.assertAccessDependency( + ["Act 2 - Battle Prospector", "Act 2 - Battle Angler", "Act 2 - Battle Trapper", "Act 2 - Battle Sawyer", + "Act 2 - Battle Royal", "Act 2 - Battle Kaycee", "Act 2 - Battle Pike Mage", "Act 2 - Battle Goobert", + "Act 2 - Battle Lonely Wizard", "Act 2 - Battle Inspector", "Act 2 - Battle Melter", + "Act 2 - Battle Dredger", "Act 2 - Tower Chest 1", "Act 2 - Tower Chest 2", "Act 2 - Tower Chest 3", + "Act 2 - Forest Meadow Chest", "Act 2 - Forest Cabin Chest", "Act 2 - Cabin Wardrobe Drawer", + "Act 2 - Cabin Safe", "Act 2 - Crypt Casket 1", "Act 2 - Crypt Casket 2", "Act 2 - Crypt Well", + "Act 2 - Camera Replica", "Act 2 - Clover", "Act 2 - Epitaph Piece 1", "Act 2 - Epitaph Piece 2", + "Act 2 - Epitaph Piece 3", "Act 2 - Epitaph Piece 4", "Act 2 - Epitaph Piece 5", "Act 2 - Epitaph Piece 6", + "Act 2 - Epitaph Piece 7", "Act 2 - Epitaph Piece 8", "Act 2 - Epitaph Piece 9", "Act 2 - Dock Chest", + "Act 2 - Tentacle", "Act 2 - Factory Trash Can", "Act 2 - Factory Drawer 1", + "Act 2 - Ancient Obol", "Act 2 - Factory Drawer 2", "Act 2 - Factory Chest 1", "Act 2 - Factory Chest 2", + "Act 2 - Factory Chest 3", "Act 2 - Factory Chest 4", "Act 2 - Monocle", "Act 2 - Boss Leshy", + "Act 2 - Boss Grimora", "Act 2 - Boss Magnificus", "Act 2 - Boss P03", "Act 2 - Mycologists Holo Key", + "Act 2 - Bone Lord Femur", "Act 2 - Bone Lord Horn", "Act 2 - Bone Lord Holo Key", + "Act 3 - Boss Photographer", "Act 3 - Boss Archivist", "Act 3 - Boss Unfinished", "Act 3 - Boss G0lly", + "Act 3 - Boss Mycologists", "Act 3 - Bone Lord Room", "Act 3 - Shop Holo Pelt", "Act 3 - Middle Holo Pelt", + "Act 3 - Forest Holo Pelt", "Act 3 - Crypt Holo Pelt", "Act 3 - Tower Holo Pelt", "Act 3 - Trader 1", + "Act 3 - Trader 2", "Act 3 - Trader 3", "Act 3 - Trader 4", "Act 3 - Trader 5", "Act 3 - Drawer 1", + "Act 3 - Drawer 2", "Act 3 - Clock", "Act 3 - Extra Battery", "Act 3 - Nano Armor Generator", + "Act 3 - Chest", "Act 3 - Goobert's Painting", "Act 3 - Luke's File Entry 1", "Act 3 - Gems Drone", + "Act 3 - Luke's File Entry 2", "Act 3 - Luke's File Entry 3", "Act 3 - Luke's File Entry 4", + "Act 3 - Inspectometer Battery", "Act 3 - Gems Drone", "Act 3 - The Great Transcendence", "Act 3 - Well"], + [["Film Roll"]] + ) + + def test_epitaphs_and_forest_items(self) -> None: + self.assertAccessDependency( + ["Act 2 - Battle Prospector", "Act 2 - Battle Angler", "Act 2 - Battle Trapper", + "Act 2 - Battle Pike Mage", "Act 2 - Battle Goobert", "Act 2 - Battle Lonely Wizard", + "Act 2 - Battle Inspector", "Act 2 - Battle Melter", "Act 2 - Battle Dredger", + "Act 2 - Tower Chest 1", "Act 2 - Tower Chest 2", "Act 2 - Tower Chest 3", "Act 2 - Forest Meadow Chest", + "Act 2 - Tentacle", "Act 2 - Factory Trash Can", "Act 2 - Factory Drawer 1", "Act 2 - Ancient Obol", + "Act 2 - Factory Drawer 2", "Act 2 - Factory Chest 1", "Act 2 - Factory Chest 2", + "Act 2 - Factory Chest 3", "Act 2 - Factory Chest 4", "Act 2 - Monocle", "Act 2 - Boss Leshy", + "Act 2 - Boss Grimora", "Act 2 - Boss Magnificus", "Act 2 - Boss P03", "Act 2 - Mycologists Holo Key", + "Act 3 - Boss Photographer", "Act 3 - Boss Archivist", "Act 3 - Boss Unfinished", "Act 3 - Boss G0lly", + "Act 3 - Boss Mycologists", "Act 3 - Bone Lord Room", "Act 3 - Shop Holo Pelt", "Act 3 - Middle Holo Pelt", + "Act 3 - Forest Holo Pelt", "Act 3 - Crypt Holo Pelt", "Act 3 - Tower Holo Pelt", "Act 3 - Trader 1", + "Act 3 - Trader 2", "Act 3 - Trader 3", "Act 3 - Trader 4", "Act 3 - Trader 5", "Act 3 - Drawer 1", + "Act 3 - Drawer 2", "Act 3 - Clock", "Act 3 - Extra Battery", "Act 3 - Nano Armor Generator", + "Act 3 - Chest", "Act 3 - Goobert's Painting", "Act 3 - Luke's File Entry 1", "Act 3 - Gems Drone", + "Act 3 - Luke's File Entry 2", "Act 3 - Luke's File Entry 3", "Act 3 - Luke's File Entry 4", + "Act 3 - Inspectometer Battery", "Act 3 - Gems Drone", "Act 3 - The Great Transcendence", "Act 3 - Well"], + [["Epitaph Piece", "Camera Replica", "Pile Of Meat"]] + ) + + def test_epitaphs(self) -> None: + self.assertAccessDependency( + ["Act 2 - Boss Grimora", + "Act 3 - Boss Photographer", "Act 3 - Boss Archivist", "Act 3 - Boss Unfinished", "Act 3 - Boss G0lly", + "Act 3 - Boss Mycologists", "Act 3 - Bone Lord Room", "Act 3 - Shop Holo Pelt", "Act 3 - Middle Holo Pelt", + "Act 3 - Forest Holo Pelt", "Act 3 - Crypt Holo Pelt", "Act 3 - Tower Holo Pelt", "Act 3 - Trader 1", + "Act 3 - Trader 2", "Act 3 - Trader 3", "Act 3 - Trader 4", "Act 3 - Trader 5", "Act 3 - Drawer 1", + "Act 3 - Drawer 2", "Act 3 - Clock", "Act 3 - Extra Battery", "Act 3 - Nano Armor Generator", + "Act 3 - Chest", "Act 3 - Goobert's Painting", "Act 3 - Luke's File Entry 1", "Act 3 - Gems Drone", + "Act 3 - Luke's File Entry 2", "Act 3 - Luke's File Entry 3", "Act 3 - Luke's File Entry 4", + "Act 3 - Inspectometer Battery", "Act 3 - Gems Drone", "Act 3 - The Great Transcendence", "Act 3 - Well"], + [["Epitaph Piece"]] + ) + + def test_forest_items(self) -> None: + self.assertAccessDependency( + ["Act 2 - Battle Prospector", "Act 2 - Battle Angler", "Act 2 - Battle Trapper", + "Act 2 - Boss Leshy", "Act 2 - Forest Meadow Chest", + "Act 3 - Boss Photographer", "Act 3 - Boss Archivist", "Act 3 - Boss Unfinished", "Act 3 - Boss G0lly", + "Act 3 - Boss Mycologists", "Act 3 - Bone Lord Room", "Act 3 - Shop Holo Pelt", "Act 3 - Middle Holo Pelt", + "Act 3 - Forest Holo Pelt", "Act 3 - Crypt Holo Pelt", "Act 3 - Tower Holo Pelt", "Act 3 - Trader 1", + "Act 3 - Trader 2", "Act 3 - Trader 3", "Act 3 - Trader 4", "Act 3 - Trader 5", "Act 3 - Drawer 1", + "Act 3 - Drawer 2", "Act 3 - Clock", "Act 3 - Extra Battery", "Act 3 - Nano Armor Generator", + "Act 3 - Chest", "Act 3 - Goobert's Painting", "Act 3 - Luke's File Entry 1", "Act 3 - Gems Drone", + "Act 3 - Luke's File Entry 2", "Act 3 - Luke's File Entry 3", "Act 3 - Luke's File Entry 4", + "Act 3 - Inspectometer Battery", "Act 3 - Gems Drone", "Act 3 - The Great Transcendence", "Act 3 - Well"], + [["Camera Replica", "Pile Of Meat"]] + ) + + def test_monocle(self) -> None: + self.assertAccessDependency( + ["Act 2 - Battle Goobert", "Act 2 - Battle Pike Mage", "Act 2 - Battle Lonely Wizard", + "Act 2 - Boss Magnificus", "Act 2 - Tower Chest 2", "Act 2 - Tower Chest 3", + "Act 2 - Tentacle", "Act 2 - Ancient Obol", "Act 2 - Mycologists Holo Key", + "Act 3 - Boss Photographer", "Act 3 - Boss Archivist", "Act 3 - Boss Unfinished", "Act 3 - Boss G0lly", + "Act 3 - Boss Mycologists", "Act 3 - Bone Lord Room", "Act 3 - Shop Holo Pelt", "Act 3 - Middle Holo Pelt", + "Act 3 - Forest Holo Pelt", "Act 3 - Crypt Holo Pelt", "Act 3 - Tower Holo Pelt", "Act 3 - Trader 1", + "Act 3 - Trader 2", "Act 3 - Trader 3", "Act 3 - Trader 4", "Act 3 - Trader 5", "Act 3 - Drawer 1", + "Act 3 - Drawer 2", "Act 3 - Clock", "Act 3 - Extra Battery", "Act 3 - Nano Armor Generator", + "Act 3 - Chest", "Act 3 - Goobert's Painting", "Act 3 - Luke's File Entry 1", "Act 3 - Gems Drone", + "Act 3 - Luke's File Entry 2", "Act 3 - Luke's File Entry 3", "Act 3 - Luke's File Entry 4", + "Act 3 - Inspectometer Battery", "Act 3 - Gems Drone", "Act 3 - The Great Transcendence", "Act 3 - Well"], + [["Monocle"]] + ) + + +class AccessTestUnordered(InscryptionTestBase): + options = { + "goal": 1, + } + + def test_epitaphs_and_forest_items(self) -> None: + self.assertAccessDependency( + ["Act 2 - Battle Prospector", "Act 2 - Battle Angler", "Act 2 - Battle Trapper", + "Act 2 - Battle Pike Mage", "Act 2 - Battle Goobert", "Act 2 - Battle Lonely Wizard", + "Act 2 - Battle Inspector", "Act 2 - Battle Melter", "Act 2 - Battle Dredger", + "Act 2 - Tower Chest 1", "Act 2 - Tower Chest 2", "Act 2 - Tower Chest 3", "Act 2 - Forest Meadow Chest", + "Act 2 - Tentacle", "Act 2 - Factory Trash Can", "Act 2 - Factory Drawer 1", "Act 2 - Ancient Obol", + "Act 2 - Factory Drawer 2", "Act 2 - Factory Chest 1", "Act 2 - Factory Chest 2", + "Act 2 - Factory Chest 3", "Act 2 - Factory Chest 4", "Act 2 - Monocle", "Act 2 - Boss Leshy", + "Act 2 - Boss Grimora", "Act 2 - Boss Magnificus", "Act 2 - Boss P03", "Act 2 - Mycologists Holo Key"], + [["Epitaph Piece", "Camera Replica", "Pile Of Meat"]] + ) + + def test_epitaphs(self) -> None: + self.assertAccessDependency( + ["Act 2 - Boss Grimora"], + [["Epitaph Piece"]] + ) + + def test_forest_items(self) -> None: + self.assertAccessDependency( + ["Act 2 - Battle Prospector", "Act 2 - Battle Angler", "Act 2 - Battle Trapper", + "Act 2 - Boss Leshy", "Act 2 - Forest Meadow Chest"], + [["Camera Replica", "Pile Of Meat"]] + ) + + def test_monocle(self) -> None: + self.assertAccessDependency( + ["Act 2 - Battle Goobert", "Act 2 - Battle Pike Mage", "Act 2 - Battle Lonely Wizard", + "Act 2 - Boss Magnificus", "Act 2 - Tower Chest 2", "Act 2 - Tower Chest 3", + "Act 2 - Tentacle", "Act 2 - Ancient Obol", "Act 2 - Mycologists Holo Key"], + [["Monocle"]] + ) + +class AccessTestBalancedPaintings(InscryptionTestBase): + options = { + "painting_checks_balancing": 1, + } + + def test_paintings(self) -> None: + self.assertAccessDependency(["Act 1 - Painting 2", "Act 1 - Painting 3"], + [["Oil Painting's Clover Plant", "Squirrel Totem Head"]]) diff --git a/worlds/inscryption/test/TestGoal.py b/worlds/inscryption/test/TestGoal.py new file mode 100644 index 000000000000..975af66e45a6 --- /dev/null +++ b/worlds/inscryption/test/TestGoal.py @@ -0,0 +1,108 @@ +from . import InscryptionTestBase + + +class GoalTestOrdered(InscryptionTestBase): + options = { + "goal": 0, + } + + def test_beatable(self) -> None: + for item_name in self.required_items_all_acts: + item = self.get_item_by_name(item_name) + self.collect(item) + for i in range(9): + item = self.get_item_by_name("Epitaph Piece") + self.collect(item) + self.assertBeatable(True) + for item_name in self.required_items_all_acts: + item = self.get_item_by_name(item_name) + self.remove(item) + self.assertBeatable(False) + self.collect(item) + item = self.get_item_by_name("Epitaph Piece") + self.remove(item) + self.assertBeatable(False) + self.collect(item) + + +class GoalTestUnordered(InscryptionTestBase): + options = { + "goal": 1, + } + + def test_beatable(self) -> None: + for item_name in self.required_items_all_acts: + item = self.get_item_by_name(item_name) + self.collect(item) + for i in range(9): + item = self.get_item_by_name("Epitaph Piece") + self.collect(item) + self.assertBeatable(True) + for item_name in self.required_items_all_acts: + item = self.get_item_by_name(item_name) + self.remove(item) + self.assertBeatable(False) + self.collect(item) + item = self.get_item_by_name("Epitaph Piece") + self.remove(item) + self.assertBeatable(False) + self.collect(item) + + +class GoalTestAct1(InscryptionTestBase): + options = { + "goal": 2, + } + + def test_beatable(self) -> None: + self.assertBeatable(False) + film_roll = self.get_item_by_name("Film Roll") + self.collect(film_roll) + self.assertBeatable(True) + + +class GoalTestGroupedEpitaphs(InscryptionTestBase): + options = { + "epitaph_pieces_randomization": 1, + } + + def test_beatable(self) -> None: + for item_name in self.required_items_all_acts: + item = self.get_item_by_name(item_name) + self.collect(item) + for i in range(3): + item = self.get_item_by_name("Epitaph Pieces") + self.collect(item) + self.assertBeatable(True) + for item_name in self.required_items_all_acts: + item = self.get_item_by_name(item_name) + self.remove(item) + self.assertBeatable(False) + self.collect(item) + item = self.get_item_by_name("Epitaph Pieces") + self.remove(item) + self.assertBeatable(False) + self.collect(item) + + +class GoalTestEpitaphsAsOne(InscryptionTestBase): + options = { + "epitaph_pieces_randomization": 2, + } + + def test_beatable(self) -> None: + for item_name in self.required_items_all_acts: + item = self.get_item_by_name(item_name) + self.collect(item) + item = self.get_item_by_name("Epitaph Pieces") + self.collect(item) + self.assertBeatable(True) + for item_name in self.required_items_all_acts: + item = self.get_item_by_name(item_name) + self.remove(item) + self.assertBeatable(False) + self.collect(item) + item = self.get_item_by_name("Epitaph Pieces") + self.remove(item) + self.assertBeatable(False) + self.collect(item) diff --git a/worlds/inscryption/test/__init__.py b/worlds/inscryption/test/__init__.py new file mode 100644 index 000000000000..31a0cd2b112e --- /dev/null +++ b/worlds/inscryption/test/__init__.py @@ -0,0 +1,7 @@ +from test.bases import WorldTestBase + + +class InscryptionTestBase(WorldTestBase): + game = "Inscryption" + required_items_all_acts = ["Film Roll", "Camera Replica", "Pile Of Meat", "Monocle", + "Inspectometer Battery", "Gems Module", "Quill"] From f3ec82962e18ec3a57e1f1984ba5270b0b0d145a Mon Sep 17 00:00:00 2001 From: Richard Snider Date: Sun, 22 Dec 2024 18:05:43 +0000 Subject: [PATCH 044/144] Core: Add JSONMessagePart for Hint Status (Hint Priority) (#4387) * add hint_status JSONMessagePart handling * add docs for hint_status JSONMessagePart * fix link ordering * Rename hint_status type in docs Co-authored-by: Emily <35015090+EmilyV99@users.noreply.github.com> * Remove redundant explanation of hint_status field Co-authored-by: Emily <35015090+EmilyV99@users.noreply.github.com> * Fix formatting on hint status docs again Co-authored-by: Emily <35015090+EmilyV99@users.noreply.github.com> --------- Co-authored-by: Emily <35015090+EmilyV99@users.noreply.github.com> --- NetUtils.py | 33 +++++++++++++++++++++++---------- docs/network protocol.md | 2 ++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/NetUtils.py b/NetUtils.py index a961850639a0..64a778c55ce8 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -10,6 +10,14 @@ from Utils import ByValue, Version +class HintStatus(enum.IntEnum): + HINT_FOUND = 0 + HINT_UNSPECIFIED = 1 + HINT_NO_PRIORITY = 10 + HINT_AVOID = 20 + HINT_PRIORITY = 30 + + class JSONMessagePart(typing.TypedDict, total=False): text: str # optional @@ -19,6 +27,8 @@ class JSONMessagePart(typing.TypedDict, total=False): player: int # if type == item indicates item flags flags: int + # if type == hint_status + hint_status: HintStatus class ClientStatus(ByValue, enum.IntEnum): @@ -29,14 +39,6 @@ class ClientStatus(ByValue, enum.IntEnum): CLIENT_GOAL = 30 -class HintStatus(enum.IntEnum): - HINT_FOUND = 0 - HINT_UNSPECIFIED = 1 - HINT_NO_PRIORITY = 10 - HINT_AVOID = 20 - HINT_PRIORITY = 30 - - class SlotType(ByValue, enum.IntFlag): spectator = 0b00 player = 0b01 @@ -192,6 +194,7 @@ class JSONTypes(str, enum.Enum): location_name = "location_name" location_id = "location_id" entrance_name = "entrance_name" + hint_status = "hint_status" class JSONtoTextParser(metaclass=HandlerMeta): @@ -273,6 +276,10 @@ def _handle_entrance_name(self, node: JSONMessagePart): node["color"] = 'blue' return self._handle_color(node) + def _handle_hint_status(self, node: JSONMessagePart): + node["color"] = status_colors.get(node["hint_status"], "red") + return self._handle_color(node) + class RawJSONtoTextParser(JSONtoTextParser): def _handle_color(self, node: JSONMessagePart): @@ -319,6 +326,13 @@ def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs) HintStatus.HINT_AVOID: "salmon", HintStatus.HINT_PRIORITY: "plum", } + + +def add_json_hint_status(parts: list, hint_status: HintStatus, text: typing.Optional[str] = None, **kwargs): + parts.append({"text": text if text != None else status_names.get(hint_status, "(unknown)"), + "hint_status": hint_status, "type": JSONTypes.hint_status, **kwargs}) + + class Hint(typing.NamedTuple): receiving_player: int finding_player: int @@ -363,8 +377,7 @@ def as_network_message(self) -> dict: else: add_json_text(parts, "'s World") add_json_text(parts, ". ") - add_json_text(parts, status_names.get(self.status, "(unknown)"), type="color", - color=status_colors.get(self.status, "red")) + add_json_hint_status(parts, self.status) return {"cmd": "PrintJSON", "data": parts, "type": "Hint", "receiving": self.receiving_player, diff --git a/docs/network protocol.md b/docs/network protocol.md index 4331cf971007..2ad8d4c4d1bc 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -554,6 +554,7 @@ class JSONMessagePart(TypedDict): color: Optional[str] # only available if type is a color flags: Optional[int] # only available if type is an item_id or item_name player: Optional[int] # only available if type is either item or location + hint_status: Optional[HintStatus] # only available if type is hint_status ``` `type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently. @@ -569,6 +570,7 @@ Possible values for `type` include: | location_id | Location ID, should be resolved to Location Name | | location_name | Location Name, not currently used over network, but supported by reference Clients. | | entrance_name | Entrance Name. No ID mapping exists. | +| hint_status | The [HintStatus](#HintStatus) of the hint. Both `text` and `hint_status` are given. | | color | Regular text that should be colored. Only `type` that will contain `color` data. | From 78637c96a747dd15584fb85a281d447b8307ebe0 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Tue, 24 Dec 2024 17:38:46 +0000 Subject: [PATCH 045/144] Tests: Add spheres test for missing indirect conditions (#3924) Co-authored-by: Fabian Dill Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- test/general/test_implemented.py | 65 ++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/general/test_implemented.py b/test/general/test_implemented.py index e76d539451ea..756cfa8bb67d 100644 --- a/test/general/test_implemented.py +++ b/test/general/test_implemented.py @@ -52,3 +52,68 @@ def test_slot_data(self): def test_no_failed_world_loads(self): if failed_world_loads: self.fail(f"The following worlds failed to load: {failed_world_loads}") + + def test_explicit_indirect_conditions_spheres(self): + """Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit + indirect conditions""" + # Because the iteration order of blocked_connections in CollectionState.update_reachable_regions() is + # nondeterministic, this test may sometimes pass with the same seed even when there are missing indirect + # conditions. + for game_name, world_type in AutoWorldRegister.world_types.items(): + multiworld = setup_solo_multiworld(world_type) + world = multiworld.get_game_worlds(game_name)[0] + if not world.explicit_indirect_conditions: + # The world does not use explicit indirect conditions, so it can be skipped. + continue + # The world may override explicit_indirect_conditions as a property that cannot be set, so try modifying it. + try: + world.explicit_indirect_conditions = False + world.explicit_indirect_conditions = True + except Exception: + # Could not modify the attribute, so skip this world. + with self.subTest(game=game_name, skipped="world.explicit_indirect_conditions could not be set"): + continue + with self.subTest(game=game_name, seed=multiworld.seed): + distribute_items_restrictive(multiworld) + call_all(multiworld, "post_fill") + + # Note: `multiworld.get_spheres()` iterates a set of locations, so the order that locations are checked + # is nondeterministic and may vary between runs with the same seed. + explicit_spheres = list(multiworld.get_spheres()) + # Disable explicit indirect conditions and produce a second list of spheres. + world.explicit_indirect_conditions = False + implicit_spheres = list(multiworld.get_spheres()) + + # Both lists should be identical. + if explicit_spheres == implicit_spheres: + # Test passed. + continue + + # Find the first sphere that was different and provide a useful failure message. + zipped = zip(explicit_spheres, implicit_spheres) + for sphere_num, (sphere_explicit, sphere_implicit) in enumerate(zipped, start=1): + # Each sphere created with explicit indirect conditions should be identical to the sphere created + # with implicit indirect conditions. + if sphere_explicit != sphere_implicit: + reachable_only_with_implicit = sorted(sphere_implicit - sphere_explicit) + if reachable_only_with_implicit: + locations_and_parents = [(loc, loc.parent_region) for loc in reachable_only_with_implicit] + self.fail(f"Sphere {sphere_num} created with explicit indirect conditions did not contain" + f" the same locations as sphere {sphere_num} created with implicit indirect" + f" conditions. There may be missing indirect conditions for connections to the" + f" locations' parent regions or connections from other regions which connect to" + f" these regions." + f"\nLocations that should have been reachable in sphere {sphere_num} and their" + f" parent regions:" + f"\n{locations_and_parents}") + else: + # Some locations were only present in the sphere created with explicit indirect conditions. + # This should not happen because missing indirect conditions should only reduce + # accessibility, not increase accessibility. + reachable_only_with_explicit = sorted(sphere_explicit - sphere_implicit) + self.fail(f"Sphere {sphere_num} created with explicit indirect conditions contained more" + f" locations than sphere {sphere_num} created with implicit indirect conditions." + f" This should not happen." + f"\nUnexpectedly reachable locations in sphere {sphere_num}:" + f"\n{reachable_only_with_explicit}") + self.fail("Unreachable") From 5578ccd578be4aff3b4542970f8bd7cacac5c526 Mon Sep 17 00:00:00 2001 From: Dinopony Date: Tue, 24 Dec 2024 20:08:03 +0100 Subject: [PATCH 046/144] Landstalker: Fix issues on generation (#4345) --- worlds/landstalker/Constants.py | 28 +++++++++++++++ worlds/landstalker/Hints.py | 2 +- worlds/landstalker/Items.py | 3 +- worlds/landstalker/Locations.py | 16 +++++---- worlds/landstalker/__init__.py | 20 ++++++++--- worlds/landstalker/data/world_node.py | 36 +++++++++++++++++++ worlds/landstalker/data/world_path.py | 25 +++++++++++++ worlds/landstalker/data/world_region.py | 13 ++++--- .../landstalker/data/world_teleport_tree.py | 10 +++--- 9 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 worlds/landstalker/Constants.py diff --git a/worlds/landstalker/Constants.py b/worlds/landstalker/Constants.py new file mode 100644 index 000000000000..ad4dc6ce7ae6 --- /dev/null +++ b/worlds/landstalker/Constants.py @@ -0,0 +1,28 @@ + +BASE_ITEM_ID = 4000 + +BASE_LOCATION_ID = 4000 +BASE_GROUND_LOCATION_ID = BASE_LOCATION_ID + 256 +BASE_SHOP_LOCATION_ID = BASE_GROUND_LOCATION_ID + 30 +BASE_REWARD_LOCATION_ID = BASE_SHOP_LOCATION_ID + 50 + +ENDGAME_REGIONS = [ + "kazalt", + "king_nole_labyrinth_pre_door", + "king_nole_labyrinth_post_door", + "king_nole_labyrinth_exterior", + "king_nole_labyrinth_fall_from_exterior", + "king_nole_labyrinth_path_to_palace", + "king_nole_labyrinth_raft_entrance", + "king_nole_labyrinth_raft", + "king_nole_labyrinth_sacred_tree", + "king_nole_palace" +] + +ENDGAME_PROGRESSION_ITEMS = [ + "Gola's Nail", + "Gola's Fang", + "Gola's Horn", + "Logs", + "Snow Spikes" +] \ No newline at end of file diff --git a/worlds/landstalker/Hints.py b/worlds/landstalker/Hints.py index 5309e85032ea..4211e0ef3bb1 100644 --- a/worlds/landstalker/Hints.py +++ b/worlds/landstalker/Hints.py @@ -45,7 +45,7 @@ def generate_lithograph_hint(world: "LandstalkerWorld"): words.append(item.name.split(" ")[0].upper()) if item.location.player != world.player: # Add player name if it's not in our own world - player_name = world.multiworld.get_player_name(world.player) + player_name = world.multiworld.get_player_name(item.location.player) words.append(player_name.upper()) world.random.shuffle(words) hint_text += " ".join(words) + "\n" diff --git a/worlds/landstalker/Items.py b/worlds/landstalker/Items.py index ad7efa1cb27a..6424a37f9a1e 100644 --- a/worlds/landstalker/Items.py +++ b/worlds/landstalker/Items.py @@ -1,8 +1,7 @@ from typing import Dict, List, NamedTuple from BaseClasses import Item, ItemClassification - -BASE_ITEM_ID = 4000 +from .Constants import BASE_ITEM_ID class LandstalkerItem(Item): diff --git a/worlds/landstalker/Locations.py b/worlds/landstalker/Locations.py index 0fe63526c63b..25d02ca527f4 100644 --- a/worlds/landstalker/Locations.py +++ b/worlds/landstalker/Locations.py @@ -1,15 +1,11 @@ from typing import Dict, Optional from BaseClasses import Location, ItemClassification, Item +from .Constants import * from .Regions import LandstalkerRegion from .data.item_source import ITEM_SOURCES_JSON from .data.world_path import WORLD_PATHS_JSON -BASE_LOCATION_ID = 4000 -BASE_GROUND_LOCATION_ID = BASE_LOCATION_ID + 256 -BASE_SHOP_LOCATION_ID = BASE_GROUND_LOCATION_ID + 30 -BASE_REWARD_LOCATION_ID = BASE_SHOP_LOCATION_ID + 50 - class LandstalkerLocation(Location): game: str = "Landstalker - The Treasures of King Nole" @@ -21,10 +17,14 @@ def __init__(self, player: int, name: str, location_id: Optional[int], region: L self.type_string = type_string -def create_locations(player: int, regions_table: Dict[str, LandstalkerRegion], name_to_id_table: Dict[str, int]): +def create_locations(player: int, regions_table: Dict[str, LandstalkerRegion], + name_to_id_table: Dict[str, int], reach_kazalt_goal: bool): # Create real locations from the data inside the corresponding JSON file for data in ITEM_SOURCES_JSON: region_id = data["nodeId"] + # If "Reach Kazalt" goal is enabled and location is beyond Kazalt, don't create it + if reach_kazalt_goal and region_id in ENDGAME_REGIONS: + continue region = regions_table[region_id] new_location = LandstalkerLocation(player, data["name"], name_to_id_table[data["name"]], region, data["type"]) region.locations.append(new_location) @@ -32,6 +32,10 @@ def create_locations(player: int, regions_table: Dict[str, LandstalkerRegion], n # Create fake event locations that will be used to determine if some key regions has been visited regions_with_entrance_checks = [] for data in WORLD_PATHS_JSON: + # If "Reach Kazalt" goal is enabled and region is beyond Kazalt, don't create any event for it since it would + # be useless anyway + if reach_kazalt_goal and data["fromId"] in ENDGAME_REGIONS: + continue if "requiredNodes" in data: regions_with_entrance_checks.extend([region_id for region_id in data["requiredNodes"]]) regions_with_entrance_checks = sorted(set(regions_with_entrance_checks)) diff --git a/worlds/landstalker/__init__.py b/worlds/landstalker/__init__.py index 8463e56e54c1..cfdc335c484e 100644 --- a/worlds/landstalker/__init__.py +++ b/worlds/landstalker/__init__.py @@ -2,6 +2,7 @@ from BaseClasses import LocationProgressType, Tutorial from worlds.AutoWorld import WebWorld, World +from .Constants import * from .Hints import * from .Items import * from .Locations import * @@ -87,7 +88,8 @@ def generate_early(self): def create_regions(self): self.regions_table = Regions.create_regions(self) - Locations.create_locations(self.player, self.regions_table, self.location_name_to_id) + Locations.create_locations(self.player, self.regions_table, self.location_name_to_id, + self.options.goal == "reach_kazalt") self.create_teleportation_trees() def create_item(self, name: str, classification_override: Optional[ItemClassification] = None) -> LandstalkerItem: @@ -109,7 +111,16 @@ def create_items(self): # If item is an armor and progressive armors are enabled, transform it into a progressive armor item if self.options.progressive_armors and "Breast" in name: name = "Progressive Armor" - item_pool += [self.create_item(name) for _ in range(data.quantity)] + + qty = data.quantity + if self.options.goal == "reach_kazalt": + # In "Reach Kazalt" goal, remove all endgame progression items that would be useless anyway + if name in ENDGAME_PROGRESSION_ITEMS: + continue + # Also reduce quantities for most filler items to let space for more EkeEke (see end of function) + if data.classification == ItemClassification.filler: + qty = int(qty * 0.8) + item_pool += [self.create_item(name) for _ in range(qty)] # If the appropriate setting is on, place one EkeEke in one shop in every town in the game if self.options.ensure_ekeeke_in_shops: @@ -120,9 +131,10 @@ def create_items(self): "Mercator: Shop item #1", "Verla: Shop item #1", "Destel: Inn item", - "Route to Lake Shrine: Greedly's shop item #1", - "Kazalt: Shop item #1" + "Route to Lake Shrine: Greedly's shop item #1" ] + if self.options.goal != "reach_kazalt": + shops_to_fill.append("Kazalt: Shop item #1") for location_name in shops_to_fill: self.multiworld.get_location(location_name, self.player).place_locked_item(self.create_item("EkeEke")) diff --git a/worlds/landstalker/data/world_node.py b/worlds/landstalker/data/world_node.py index f786f9613fba..0b0c56a74e69 100644 --- a/worlds/landstalker/data/world_node.py +++ b/worlds/landstalker/data/world_node.py @@ -73,6 +73,22 @@ "between Gumi and Ryuma" ] }, + "tibor_tree": { + "name": "Route from Gumi to Ryuma (Tibor tree)", + "hints": [ + "on a route", + "in a region inhabited by bears", + "between Gumi and Ryuma" + ] + }, + "mercator_gate_tree": { + "name": "Route from Gumi to Ryuma (Mercator gate tree)", + "hints": [ + "on a route", + "in a region inhabited by bears", + "between Gumi and Ryuma" + ] + }, "tibor": { "name": "Tibor", "hints": [ @@ -223,6 +239,13 @@ "in the infamous Greenmaze" ] }, + "greenmaze_post_whistle_tree": { + "name": "Greenmaze (post-whistle tree)", + "hints": [ + "among the trees", + "in the infamous Greenmaze" + ] + }, "verla_shore": { "name": "Verla shore", "hints": [ @@ -230,6 +253,13 @@ "near the town of Verla" ] }, + "verla_shore_tree": { + "name": "Verla shore tree", + "hints": [ + "on a route", + "near the town of Verla" + ] + }, "verla_shore_cliff": { "name": "Verla shore cliff (accessible from Verla Mines)", "hints": [ @@ -326,6 +356,12 @@ "in a mountainous area" ] }, + "mountainous_area_tree": { + "name": "Mountainous Area tree", + "hints": [ + "in a mountainous area" + ] + }, "king_nole_cave": { "name": "King Nole's Cave", "hints": [ diff --git a/worlds/landstalker/data/world_path.py b/worlds/landstalker/data/world_path.py index f7baba358a48..572149a73529 100644 --- a/worlds/landstalker/data/world_path.py +++ b/worlds/landstalker/data/world_path.py @@ -54,6 +54,16 @@ "toId": "ryuma", "twoWay": True }, + { + "fromId": "route_gumi_ryuma", + "toId": "tibor_tree", + "twoWay": True + }, + { + "fromId": "route_gumi_ryuma", + "toId": "mercator_gate_tree", + "twoWay": True + }, { "fromId": "ryuma", "toId": "ryuma_after_thieves_hideout", @@ -211,6 +221,11 @@ ], "twoWay": True }, + { + "fromId": "greenmaze_post_whistle", + "toId": "greenmaze_post_whistle_tree", + "twoWay": True + }, { "fromId": "greenmaze_post_whistle", "toId": "route_massan_gumi" @@ -253,6 +268,11 @@ "fromId": "verla_shore_cliff", "toId": "verla_shore" }, + { + "fromId": "verla_shore", + "toId": "verla_shore_tree", + "twoWay": True + }, { "fromId": "verla_shore", "toId": "mir_tower_sector", @@ -316,6 +336,11 @@ "Axe Magic" ] }, + { + "fromId": "mountainous_area", + "toId": "mountainous_area_tree", + "twoWay": True + }, { "fromId": "mountainous_area", "toId": "route_lake_shrine_cliff", diff --git a/worlds/landstalker/data/world_region.py b/worlds/landstalker/data/world_region.py index 3365a9dfa9e2..81ff94452257 100644 --- a/worlds/landstalker/data/world_region.py +++ b/worlds/landstalker/data/world_region.py @@ -57,7 +57,9 @@ "name": "Route between Gumi and Ryuma", "canBeHintedAsRequired": False, "nodeIds": [ - "route_gumi_ryuma" + "route_gumi_ryuma", + "tibor_tree", + "mercator_gate_tree" ] }, { @@ -157,7 +159,8 @@ "hintName": "in Greenmaze", "nodeIds": [ "greenmaze_pre_whistle", - "greenmaze_post_whistle" + "greenmaze_post_whistle", + "greenmaze_post_whistle_tree" ] }, { @@ -165,7 +168,8 @@ "canBeHintedAsRequired": False, "nodeIds": [ "verla_shore", - "verla_shore_cliff" + "verla_shore_cliff", + "verla_shore_tree" ] }, { @@ -244,7 +248,8 @@ "name": "Mountainous Area", "hintName": "in the mountainous area", "nodeIds": [ - "mountainous_area" + "mountainous_area", + "mountainous_area_tree" ] }, { diff --git a/worlds/landstalker/data/world_teleport_tree.py b/worlds/landstalker/data/world_teleport_tree.py index 830f5547201e..f3b92affd3a6 100644 --- a/worlds/landstalker/data/world_teleport_tree.py +++ b/worlds/landstalker/data/world_teleport_tree.py @@ -8,19 +8,19 @@ { "name": "Tibor tree", "treeMapId": 534, - "nodeId": "route_gumi_ryuma" + "nodeId": "tibor_tree" } ], [ { "name": "Mercator front gate tree", "treeMapId": 539, - "nodeId": "route_gumi_ryuma" + "nodeId": "mercator_gate_tree" }, { "name": "Verla shore tree", "treeMapId": 537, - "nodeId": "verla_shore" + "nodeId": "verla_shore_tree" } ], [ @@ -44,7 +44,7 @@ { "name": "Mountainous area tree", "treeMapId": 535, - "nodeId": "mountainous_area" + "nodeId": "mountainous_area_tree" } ], [ @@ -56,7 +56,7 @@ { "name": "Greenmaze end tree", "treeMapId": 511, - "nodeId": "greenmaze_post_whistle" + "nodeId": "greenmaze_post_whistle_tree" } ] ] \ No newline at end of file From 6c1dc5f645ad215347eaecc2a5f5de0d2fd13365 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Wed, 25 Dec 2024 01:44:47 +0000 Subject: [PATCH 047/144] Landstalker: Fix paths Lantern logic affecting other Landstalker worlds (#4394) The data from `WORLD_PATHS_JSON` is supposed to be constant logic data shared by all Landstalker worlds, but `add_path_requirements()` was modifying this data such that after adding a `Lantern` requirement for a dark region, subsequent Landstalker worlds to have their logic set could also be affected by this `Lantern` requirement and previous Landstalker worlds without damage boosting logic could also be affected by this `Lantern` requirement because they could all be using the same list instances. This issue would only occur for paths that have `"requiredItems"` because all paths without required items would create a new empty list, avoiding the problem. The items in `data["itemsPlacedWhenCrossing"]` were also getting added once for each Landstalker player, but there are no paths that have both `"itemsPlacedWhenCrossing"` and `"requiredItems"`, so all such cases would start from a new empty list of required items and avoid modifying `WORLD_PATHS_JSON`. --- worlds/landstalker/Rules.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/landstalker/Rules.py b/worlds/landstalker/Rules.py index 94171944d7b2..60f4cdde2901 100644 --- a/worlds/landstalker/Rules.py +++ b/worlds/landstalker/Rules.py @@ -37,7 +37,8 @@ def add_path_requirements(world: "LandstalkerWorld"): name = data["fromId"] + " -> " + data["toId"] # Determine required items to reach this region - required_items = data["requiredItems"] if "requiredItems" in data else [] + # WORLD_PATHS_JSON is shared by all Landstalker worlds, so a copy is made to prevent modifying the original + required_items = data["requiredItems"].copy() if "requiredItems" in data else [] if "itemsPlacedWhenCrossing" in data: required_items += data["itemsPlacedWhenCrossing"] From b05f81b4b4f8f63368c7ebcf0aa5d3223357e1ce Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 25 Dec 2024 10:58:27 +0100 Subject: [PATCH 048/144] =?UTF-8?q?The=20Witness:=20Fix=20bridge/elevator?= =?UTF-8?q?=20items=20being=20progression=20when=20they=20shouldn't=20be?= =?UTF-8?q?=C2=A0#4392?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- worlds/witness/player_logic.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 9e6c9597382b..aea2953abb50 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -927,7 +927,6 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: # Gather quick references to relevant options eps_shuffled = world.options.shuffle_EPs - come_to_you = world.options.elevators_come_to_you difficulty = world.options.puzzle_randomization discards_shuffled = world.options.shuffle_discarded_panels boat_shuffled = world.options.shuffle_boat @@ -939,6 +938,9 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: shortbox_req = world.options.mountain_lasers longbox_req = world.options.challenge_lasers + swamp_bridge_comes_to_you = "Swamp Long Bridge" in world.options.elevators_come_to_you + quarry_elevator_comes_to_you = "Quarry Elevator" in world.options.elevators_come_to_you + # Make some helper booleans so it is easier to follow what's going on mountain_upper_is_in_postgame = ( goal == "mountain_box_short" @@ -956,8 +958,8 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: "0x17D02": eps_shuffled, # Windmill Turn Control "0x0368A": symbols_shuffled or door_panels, # Quarry Stoneworks Stairs Door "0x3865F": symbols_shuffled or door_panels or eps_shuffled, # Quarry Boathouse 2nd Barrier - "0x17CC4": come_to_you or eps_shuffled, # Quarry Elevator Panel - "0x17E2B": come_to_you and boat_shuffled or eps_shuffled, # Swamp Long Bridge + "0x17CC4": quarry_elevator_comes_to_you or eps_shuffled, # Quarry Elevator Panel + "0x17E2B": swamp_bridge_comes_to_you and boat_shuffled or eps_shuffled, # Swamp Long Bridge "0x0CF2A": False, # Jungle Monastery Garden Shortcut "0x0364E": False, # Monastery Laser Shortcut Door "0x03713": remote_doors, # Monastery Laser Shortcut Panel From 845000d10faa8cdf1c6ac293dcdfecc4c69a213d Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 25 Dec 2024 21:47:17 +0100 Subject: [PATCH 049/144] Docs: Make an actual LogicMixin spec & explanation (#3975) * Docs: Make an actual LogicMixin spec & explanation * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update docs/world api.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update docs/world api.md * Update world api.md * Code corrections / actually follow own spec * Update docs/world api.md Co-authored-by: Scipio Wright * Update world api.md * Update world api.md * Reorganize / Rewrite the parts about optimisations a bit * Update world api.md * Write a big motivation paragraph * Update world api.md * Update world api.md * line break issues * Update docs/world api.md Co-authored-by: Scipio Wright * Update docs/world api.md Co-authored-by: Scipio Wright * Update docs/world api.md Co-authored-by: Scipio Wright * Update world api.md * Update docs/world api.md Co-authored-by: Scipio Wright --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Co-authored-by: Scipio Wright --- docs/world api.md | 89 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/docs/world api.md b/docs/world api.md index 20669d7ae7be..445e68e71e3c 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -699,9 +699,92 @@ When importing a file that defines a class that inherits from `worlds.AutoWorld. is automatically extended by the mixin's members. These members should be prefixed with the name of the implementing world since the namespace is shared with all other logic mixins. -Some uses could be to add additional variables to the state object, or to have a custom state machine that gets modified -with the state. -Please do this with caution and only when necessary. +LogicMixin is handy when your logic is more complex than one-to-one location-item relationships. +A game in which "The red key opens the red door" can just express this relationship through a one-line access rule. +But now, consider a game with a heavy focus on combat, where the main logical consideration is which enemies you can +defeat with your current items. +There could be dozens of weapons, armor pieces, or consumables that each improve your ability to defeat +specific enemies to varying degrees. It would be useful to be able to keep track of "defeatable enemies" as a state variable, +and have this variable be recalculated as necessary based on newly collected/removed items. +This is the capability of LogicMixin: Adding custom variables to state that get recalculated as necessary. + +In general, a LogicMixin class should have at least one mutable variable that is tracking some custom state per player, +as well as `init_mixin` and `copy_mixin` functions so that this variable gets initialized and copied correctly when +`CollectionState()` and `CollectionState.copy()` are called respectively. + +```python +from BaseClasses import CollectionState, MultiWorld +from worlds.AutoWorld import LogicMixin + +class MyGameState(LogicMixin): + mygame_defeatable_enemies: Dict[int, Set[str]] # per player + + def init_mixin(self, multiworld: MultiWorld) -> None: + # Initialize per player with the corresponding "nothing" value, such as 0 or an empty set. + # You can also use something like Collections.defaultdict + self.mygame_defeatable_enemies = { + player: set() for player in multiworld.get_game_players("My Game") + } + + def copy_mixin(self, new_state: CollectionState) -> CollectionState: + # Be careful to make a "deep enough" copy here! + new_state.mygame_defeatable_enemies = { + player: enemies.copy() for player, enemies in self.mygame_defeatable_enemies.items() + } +``` + +After doing this, you can now access `state.mygame_defeatable_enemies[player]` from your access rules. + +Usually, doing this coincides with an override of `World.collect` and `World.remove`, where the custom state variable +gets recalculated when a relevant item is collected or removed. + +```python +# __init__.py + +def collect(self, state: CollectionState, item: Item) -> bool: + change = super().collect(state, item) + if change and item in COMBAT_ITEMS: + state.mygame_defeatable_enemies[self.player] |= get_newly_unlocked_enemies(state) + return change + +def remove(self, state: CollectionState, item: Item) -> bool: + change = super().remove(state, item) + if change and item in COMBAT_ITEMS: + state.mygame_defeatable_enemies[self.player] -= get_newly_locked_enemies(state) + return change +``` + +Using LogicMixin can greatly slow down your code if you don't use it intelligently. This is because `collect` +and `remove` are called very frequently during fill. If your `collect` & `remove` cause a heavy calculation +every time, your code might end up being *slower* than just doing calculations in your access rules. + +One way to optimise recalculations is to make use of the fact that `collect` should only unlock things, +and `remove` should only lock things. +In our example, we have two different functions: `get_newly_unlocked_enemies` and `get_newly_locked_enemies`. +`get_newly_unlocked_enemies` should only consider enemies that are *not already in the set* +and check whether they were **unlocked**. +`get_newly_locked_enemies` should only consider enemies that are *already in the set* +and check whether they **became locked**. + +Another impactful way to optimise LogicMixin is to use caching. +Your custom state variables don't actually need to be recalculated on every `collect` / `remove`, because there are +often multiple calls to `collect` / `remove` between access rule calls. Thus, it would be much more efficient to hold +off on recaculating until the an actual access rule call happens. +A common way to realize this is to define a `mygame_state_is_stale` variable that is set to True in `collect`, `remove`, +and `init_mixin`. The calls to the actual recalculating functions are then moved to the start of the relevant +access rules like this: + +```python +def can_defeat_enemy(state: CollectionState, player: int, enemy: str) -> bool: + if state.mygame_state_is_stale[player]: + state.mygame_defeatable_enemies[player] = recalculate_defeatable_enemies(state) + state.mygame_state_is_stale[player] = False + + return enemy in state.mygame_defeatable_enemies[player] +``` + +Only use LogicMixin if necessary. There are often other ways to achieve what it does, like making clever use of +`state.prog_items`, using event items, pseudo-regions, etc. #### pre_fill From 222c8aa0ae0ebbedb9884812087c38e15e381ed1 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 25 Dec 2024 21:47:51 +0100 Subject: [PATCH 050/144] Core: Reword item classification definitions to allow for progression + useful (#3925) * Core: Reword item classification definitions to allow for progression + useful * Update network protocol.md * Update world api.md * Update Fill.py * Docstrings * Update BaseClasses.py * Update advanced_settings_en.md * Update advanced_settings_en.md * Update advanced_settings_en.md * space --- BaseClasses.py | 27 +++++++++++++++------ Fill.py | 2 +- docs/network protocol.md | 2 +- docs/world api.md | 3 ++- worlds/generic/docs/advanced_settings_en.md | 4 +-- 5 files changed, 26 insertions(+), 12 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 98ada4f861ec..e5c187b9117f 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1254,13 +1254,26 @@ def hint_text(self) -> str: class ItemClassification(IntFlag): - filler = 0b0000 # aka trash, as in filler items like ammo, currency etc, - progression = 0b0001 # Item that is logically relevant - useful = 0b0010 # Item that is generally quite useful, but not required for anything logical - trap = 0b0100 # detrimental item - skip_balancing = 0b1000 # should technically never occur on its own - # Item that is logically relevant, but progression balancing should not touch. - # Typically currency or other counted items. + filler = 0b0000 + """ aka trash, as in filler items like ammo, currency etc """ + + progression = 0b0001 + """ Item that is logically relevant. + Protects this item from being placed on excluded or unreachable locations. """ + + useful = 0b0010 + """ Item that is especially useful. + Protects this item from being placed on excluded or unreachable locations. + When combined with another flag like "progression", it means "an especially useful progression item". """ + + trap = 0b0100 + """ Item that is detrimental in some way. """ + + skip_balancing = 0b1000 + """ should technically never occur on its own + Item that is logically relevant, but progression balancing should not touch. + Typically currency or other counted items. """ + progression_skip_balancing = 0b1001 # only progression gets balanced def as_flag(self) -> int: diff --git a/Fill.py b/Fill.py index 86a4639c51ce..45c4def9e322 100644 --- a/Fill.py +++ b/Fill.py @@ -537,7 +537,7 @@ def mark_for_locking(location: Location): if excludedlocations: raise FillError( f"Not enough filler items for excluded locations. " - f"There are {len(excludedlocations)} more excluded locations than filler or trap items.", + f"There are {len(excludedlocations)} more excluded locations than excludable items.", multiworld=multiworld, ) diff --git a/docs/network protocol.md b/docs/network protocol.md index 2ad8d4c4d1bc..160f83031c9b 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -540,7 +540,7 @@ In JSON this may look like: | ----- | ----- | | 0 | Nothing special about this item | | 0b001 | If set, indicates the item can unlock logical advancement | -| 0b010 | If set, indicates the item is important but not in a way that unlocks advancement | +| 0b010 | If set, indicates the item is especially useful | | 0b100 | If set, indicates the item is a trap | ### JSONMessagePart diff --git a/docs/world api.md b/docs/world api.md index 445e68e71e3c..487c5b4a360c 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -248,7 +248,8 @@ will all have the same ID. Name must not be numeric (must contain at least 1 let Other classifications include: * `filler`: a regular item or trash item -* `useful`: generally quite useful, but not required for anything logical. Cannot be placed on excluded locations +* `useful`: item that is especially useful. Cannot be placed on excluded or unreachable locations. When combined with +another flag like "progression", it means "an especially useful progression item". * `trap`: negative impact on the player * `skip_balancing`: denotes that an item should not be moved to an earlier sphere for the purpose of balancing (to be combined with `progression`; see below) diff --git a/worlds/generic/docs/advanced_settings_en.md b/worlds/generic/docs/advanced_settings_en.md index 2197c0708e9c..e78eb91592a3 100644 --- a/worlds/generic/docs/advanced_settings_en.md +++ b/worlds/generic/docs/advanced_settings_en.md @@ -131,8 +131,8 @@ guide: [Archipelago Plando Guide](/tutorial/Archipelago/plando/en) the location without using any hint points. * `start_location_hints` is the same as `start_hints` but for locations, allowing you to hint for the item contained there without using any hint points. -* `exclude_locations` lets you define any locations that you don't want to do and forces a filler or trap item which - isn't necessary for progression into these locations. +* `exclude_locations` lets you define any locations that you don't want to do and prevents items classified as + "progression" or "useful" from being placed on them. * `priority_locations` lets you define any locations that you want to do and forces a progression item into these locations. * `item_links` allows players to link their items into a group with the same item link name and game. The items declared From fe810535211ca9ab57ed3b7649a272035d59e3a7 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 25 Dec 2024 21:53:05 +0100 Subject: [PATCH 051/144] Core: Give the option to worlds to have a remaining fill that respects excluded locations (#3738) * Give the option to worlds to have a remaining fill that respects excluded * comment --- Fill.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Fill.py b/Fill.py index 45c4def9e322..5bbbfa79c28f 100644 --- a/Fill.py +++ b/Fill.py @@ -235,18 +235,30 @@ def remaining_fill(multiworld: MultiWorld, locations: typing.List[Location], itempool: typing.List[Item], name: str = "Remaining", - move_unplaceable_to_start_inventory: bool = False) -> None: + move_unplaceable_to_start_inventory: bool = False, + check_location_can_fill: bool = False) -> None: unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() total = min(len(itempool), len(locations)) placed = 0 + + # Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule + if check_location_can_fill: + state = CollectionState(multiworld) + + def location_can_fill_item(location_to_fill: Location, item_to_fill: Item): + return location_to_fill.can_fill(state, item_to_fill, check_access=False) + else: + def location_can_fill_item(location_to_fill: Location, item_to_fill: Item): + return location_to_fill.item_rule(item_to_fill) + while locations and itempool: item_to_place = itempool.pop() spot_to_fill: typing.Optional[Location] = None for i, location in enumerate(locations): - if location.item_rule(item_to_place): + if location_can_fill_item(location, item_to_place): # popping by index is faster than removing by content, spot_to_fill = locations.pop(i) # skipping a scan for the element @@ -267,7 +279,7 @@ def remaining_fill(multiworld: MultiWorld, location.item = None placed_item.location = None - if location.item_rule(item_to_place): + if location_can_fill_item(location, item_to_place): # Add this item to the existing placement, and # add the old item to the back of the queue spot_to_fill = placements.pop(i) From 62942704bdea4ba0f79cb88580d5214b31b750b5 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 25 Dec 2024 21:55:15 +0100 Subject: [PATCH 052/144] The Witness: Add info about which door items exist in the pool to slot data (#3583) * This feature is just broken lol * simplify * mypy * Expand the unit test for forbidden doors --- worlds/witness/__init__.py | 9 ++--- worlds/witness/player_items.py | 19 +++------- worlds/witness/test/test_door_shuffle.py | 47 ++++++++++++++++++++---- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index ac9197bd92bb..471d030d4897 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -84,7 +84,8 @@ def _get_slot_data(self) -> Dict[str, Any]: "victory_location": int(self.player_logic.VICTORY_LOCATION, 16), "panelhex_to_id": self.player_locations.CHECK_PANELHEX_TO_ID, "item_id_to_door_hexes": static_witness_items.get_item_to_door_mappings(), - "door_hexes_in_the_pool": self.player_items.get_door_ids_in_pool(), + "door_items_in_the_pool": self.player_items.get_door_item_ids_in_pool(), + "doors_that_shouldnt_be_locked": [int(h, 16) for h in self.player_logic.FORBIDDEN_DOORS], "symbols_not_in_the_game": self.player_items.get_symbol_ids_not_in_pool(), "disabled_entities": [int(h, 16) for h in self.player_logic.COMPLETELY_DISABLED_ENTITIES], "hunt_entities": [int(h, 16) for h in self.player_logic.HUNT_ENTITIES], @@ -150,7 +151,8 @@ def generate_early(self) -> None: ) self.player_regions: WitnessPlayerRegions = WitnessPlayerRegions(self.player_locations, self) - self.log_ids_to_hints = {} + self.log_ids_to_hints: Dict[int, CompactHintData] = {} + self.laser_ids_to_hints: Dict[int, CompactHintData] = {} self.determine_sufficient_progression() @@ -325,9 +327,6 @@ def create_items(self) -> None: self.options.local_items.value.add(item_name) def fill_slot_data(self) -> Dict[str, Any]: - self.log_ids_to_hints: Dict[int, CompactHintData] = {} - self.laser_ids_to_hints: Dict[int, CompactHintData] = {} - already_hinted_locations = set() # Laser hints diff --git a/worlds/witness/player_items.py b/worlds/witness/player_items.py index 2fb987bb456a..e40d261d8a97 100644 --- a/worlds/witness/player_items.py +++ b/worlds/witness/player_items.py @@ -222,20 +222,15 @@ def get_early_items(self) -> List[str]: # Sort the output for consistency across versions if the implementation changes but the logic does not. return sorted(output) - def get_door_ids_in_pool(self) -> List[int]: + def get_door_item_ids_in_pool(self) -> List[int]: """ - Returns the total set of all door IDs that are controlled by items in the pool. + Returns the ids of all door items that exist in the pool. """ - output: List[int] = [] - for item_name, item_data in self.item_data.items(): - if not isinstance(item_data.definition, DoorItemDefinition): - continue - - output += [int(hex_string, 16) for hex_string in item_data.definition.panel_id_hexes - if hex_string not in self._logic.FORBIDDEN_DOORS] - - return output + return [ + cast_not_none(item_data.ap_code) for item_data in self.item_data.values() + if isinstance(item_data.definition, DoorItemDefinition) + ] def get_symbol_ids_not_in_pool(self) -> List[int]: """ @@ -257,5 +252,3 @@ def get_progressive_item_ids_in_pool(self) -> Dict[int, List[int]]: output[cast_not_none(item.ap_code)] = [cast_not_none(static_witness_items.ITEM_DATA[child_item].ap_code) for child_item in item.definition.child_item_names] return output - - diff --git a/worlds/witness/test/test_door_shuffle.py b/worlds/witness/test/test_door_shuffle.py index d593a84bdb8f..ca4d6e0aa83e 100644 --- a/worlds/witness/test/test_door_shuffle.py +++ b/worlds/witness/test/test_door_shuffle.py @@ -1,3 +1,6 @@ +from typing import cast + +from .. import WitnessWorld from ..test import WitnessMultiworldTestBase, WitnessTestBase @@ -32,6 +35,10 @@ class TestForbiddenDoors(WitnessMultiworldTestBase): { "early_caves": "add_to_pool", }, + { + "early_caves": "add_to_pool", + "door_groupings": "regional", + }, ] common_options = { @@ -40,11 +47,35 @@ class TestForbiddenDoors(WitnessMultiworldTestBase): } def test_forbidden_doors(self) -> None: - self.assertTrue( - self.get_items_by_name("Caves Mountain Shortcut (Panel)", 1), - "Caves Mountain Shortcut (Panel) should exist in panels shuffle, but it didn't." - ) - self.assertFalse( - self.get_items_by_name("Caves Mountain Shortcut (Panel)", 2), - "Caves Mountain Shortcut (Panel) should be removed when Early Caves is enabled, but it still exists." - ) + with self.subTest("Test that Caves Mountain Shortcut (Panel) exists if Early Caves is off"): + self.assertTrue( + self.get_items_by_name("Caves Mountain Shortcut (Panel)", 1), + "Caves Mountain Shortcut (Panel) should exist in panels shuffle, but it didn't." + ) + + with self.subTest("Test that Caves Mountain Shortcut (Panel) doesn't exist if Early Caves is start_to_pool"): + self.assertFalse( + self.get_items_by_name("Caves Mountain Shortcut (Panel)", 2), + "Caves Mountain Shortcut (Panel) should be removed when Early Caves is enabled, but it still exists." + ) + + with self.subTest("Test that slot data is set up correctly for a panels seed with Early Caves"): + slot_data = cast(WitnessWorld, self.multiworld.worlds[3])._get_slot_data() + + self.assertIn( + WitnessWorld.item_name_to_id["Caves Panels"], + slot_data["door_items_in_the_pool"], + 'Caves Panels should still exist in slot_data under "door_items_in_the_pool".' + ) + + self.assertIn( + 0x021D7, + slot_data["item_id_to_door_hexes"][WitnessWorld.item_name_to_id["Caves Panels"]], + "Caves Panels should still contain Caves Mountain Shortcut Panel as a door they unlock.", + ) + + self.assertIn( + 0x021D7, + slot_data["doors_that_shouldnt_be_locked"], + "Caves Mountain Shortcut Panel should be marked as \"shouldn't be locked\".", + ) From 33ae68c756f71eac6203b302db0144dee04ab09f Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Thu, 26 Dec 2024 13:50:18 +0000 Subject: [PATCH 053/144] DS3: Convert post_fill to stage_post_fill for better performance (#4122) Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- worlds/dark_souls_3/__init__.py | 214 +++++++++++++++++--------------- 1 file changed, 117 insertions(+), 97 deletions(-) diff --git a/worlds/dark_souls_3/__init__.py b/worlds/dark_souls_3/__init__.py index 765ffb1fc544..e1787a9a44aa 100644 --- a/worlds/dark_souls_3/__init__.py +++ b/worlds/dark_souls_3/__init__.py @@ -1366,7 +1366,8 @@ def write_spoiler(self, spoiler_handle: TextIO) -> None: text = "\n" + text + "\n" spoiler_handle.write(text) - def post_fill(self): + @classmethod + def stage_post_fill(cls, multiworld: MultiWorld): """If item smoothing is enabled, rearrange items so they scale up smoothly through the run. This determines the approximate order a given silo of items (say, soul items) show up in the @@ -1375,106 +1376,125 @@ def post_fill(self): items, later spheres get higher-level ones. Within a sphere, items in DS3 are distributed in region order, and then the best items in a sphere go into the multiworld. """ + ds3_worlds = [world for world in cast(List[DarkSouls3World], multiworld.get_game_worlds(cls.game)) if + world.options.smooth_upgrade_items + or world.options.smooth_soul_items + or world.options.smooth_upgraded_weapons] + if not ds3_worlds: + # No worlds need item smoothing. + return - locations_by_sphere = [ - sorted(loc for loc in sphere if loc.item.player == self.player and not loc.locked) - for sphere in self.multiworld.get_spheres() - ] - - # All items in the base game in approximately the order they appear - all_item_order: List[DS3ItemData] = [ - item_dictionary[location.default_item_name] - for region in region_order - # Shuffle locations within each region. - for location in self._shuffle(location_tables[region]) - if self._is_location_available(location) - ] - - # All DarkSouls3Items for this world that have been assigned anywhere, grouped by name - full_items_by_name: Dict[str, List[DarkSouls3Item]] = defaultdict(list) - for location in self.multiworld.get_filled_locations(): - if location.item.player == self.player and ( - location.player != self.player or self._is_location_available(location) - ): - full_items_by_name[location.item.name].append(location.item) - - def smooth_items(item_order: List[Union[DS3ItemData, DarkSouls3Item]]) -> None: - """Rearrange all items in item_order to match that order. - - Note: this requires that item_order exactly matches the number of placed items from this - world matching the given names. - """ - - # Convert items to full DarkSouls3Items. - converted_item_order: List[DarkSouls3Item] = [ - item for item in ( - ( - # full_items_by_name won't contain DLC items if the DLC is disabled. - (full_items_by_name[item.name] or [None]).pop(0) - if isinstance(item, DS3ItemData) else item - ) - for item in item_order - ) - # Never re-order event items, because they weren't randomized in the first place. - if item and item.code is not None - ] - - names = {item.name for item in converted_item_order} - - all_matching_locations = [ - loc - for sphere in locations_by_sphere - for loc in sphere - if loc.item.name in names + spheres_per_player: Dict[int, List[List[Location]]] = {world.player: [] for world in ds3_worlds} + for sphere in multiworld.get_spheres(): + locations_per_item_player: Dict[int, List[Location]] = {player: [] for player in spheres_per_player.keys()} + for location in sphere: + if location.locked: + continue + item_player = location.item.player + if item_player in locations_per_item_player: + locations_per_item_player[item_player].append(location) + for player, locations in locations_per_item_player.items(): + # Sort for deterministic results. + locations.sort() + spheres_per_player[player].append(locations) + + for ds3_world in ds3_worlds: + locations_by_sphere = spheres_per_player[ds3_world.player] + + # All items in the base game in approximately the order they appear + all_item_order: List[DS3ItemData] = [ + item_dictionary[location.default_item_name] + for region in region_order + # Shuffle locations within each region. + for location in ds3_world._shuffle(location_tables[region]) + if ds3_world._is_location_available(location) ] - # It's expected that there may be more total items than there are matching locations if - # the player has chosen a more limited accessibility option, since the matching - # locations *only* include items in the spheres of accessibility. - if len(converted_item_order) < len(all_matching_locations): - raise Exception( - f"DS3 bug: there are {len(all_matching_locations)} locations that can " + - f"contain smoothed items, but only {len(converted_item_order)} items to smooth." - ) - - for sphere in locations_by_sphere: - locations = [loc for loc in sphere if loc.item.name in names] - - # Check the game, not the player, because we know how to sort within regions for DS3 - offworld = self._shuffle([loc for loc in locations if loc.game != "Dark Souls III"]) - onworld = sorted((loc for loc in locations if loc.game == "Dark Souls III"), - key=lambda loc: loc.data.region_value) - - # Give offworld regions the last (best) items within a given sphere - for location in onworld + offworld: - new_item = self._pop_item(location, converted_item_order) - location.item = new_item - new_item.location = location - - if self.options.smooth_upgrade_items: - base_names = { - "Titanite Shard", "Large Titanite Shard", "Titanite Chunk", "Titanite Slab", - "Titanite Scale", "Twinkling Titanite", "Farron Coal", "Sage's Coal", "Giant's Coal", - "Profaned Coal" - } - smooth_items([item for item in all_item_order if item.base_name in base_names]) - - if self.options.smooth_soul_items: - smooth_items([ - item for item in all_item_order - if item.souls and item.classification != ItemClassification.progression - ]) + # All DarkSouls3Items for this world that have been assigned anywhere, grouped by name + full_items_by_name: Dict[str, List[DarkSouls3Item]] = defaultdict(list) + for location in multiworld.get_filled_locations(): + if location.item.player == ds3_world.player and ( + location.player != ds3_world.player or ds3_world._is_location_available(location) + ): + full_items_by_name[location.item.name].append(location.item) + + def smooth_items(item_order: List[Union[DS3ItemData, DarkSouls3Item]]) -> None: + """Rearrange all items in item_order to match that order. + + Note: this requires that item_order exactly matches the number of placed items from this + world matching the given names. + """ + + # Convert items to full DarkSouls3Items. + converted_item_order: List[DarkSouls3Item] = [ + item for item in ( + ( + # full_items_by_name won't contain DLC items if the DLC is disabled. + (full_items_by_name[item.name] or [None]).pop(0) + if isinstance(item, DS3ItemData) else item + ) + for item in item_order + ) + # Never re-order event items, because they weren't randomized in the first place. + if item and item.code is not None + ] + + names = {item.name for item in converted_item_order} + + all_matching_locations = [ + loc + for sphere in locations_by_sphere + for loc in sphere + if loc.item.name in names + ] + + # It's expected that there may be more total items than there are matching locations if + # the player has chosen a more limited accessibility option, since the matching + # locations *only* include items in the spheres of accessibility. + if len(converted_item_order) < len(all_matching_locations): + raise Exception( + f"DS3 bug: there are {len(all_matching_locations)} locations that can " + + f"contain smoothed items, but only {len(converted_item_order)} items to smooth." + ) - if self.options.smooth_upgraded_weapons: - upgraded_weapons = [ - location.item - for location in self.multiworld.get_filled_locations() - if location.item.player == self.player - and location.item.level and location.item.level > 0 - and location.item.classification != ItemClassification.progression - ] - upgraded_weapons.sort(key=lambda item: item.level) - smooth_items(upgraded_weapons) + for sphere in locations_by_sphere: + locations = [loc for loc in sphere if loc.item.name in names] + + # Check the game, not the player, because we know how to sort within regions for DS3 + offworld = ds3_world._shuffle([loc for loc in locations if loc.game != "Dark Souls III"]) + onworld = sorted((loc for loc in locations if loc.game == "Dark Souls III"), + key=lambda loc: loc.data.region_value) + + # Give offworld regions the last (best) items within a given sphere + for location in onworld + offworld: + new_item = ds3_world._pop_item(location, converted_item_order) + location.item = new_item + new_item.location = location + + if ds3_world.options.smooth_upgrade_items: + base_names = { + "Titanite Shard", "Large Titanite Shard", "Titanite Chunk", "Titanite Slab", + "Titanite Scale", "Twinkling Titanite", "Farron Coal", "Sage's Coal", "Giant's Coal", + "Profaned Coal" + } + smooth_items([item for item in all_item_order if item.base_name in base_names]) + + if ds3_world.options.smooth_soul_items: + smooth_items([ + item for item in all_item_order + if item.souls and item.classification != ItemClassification.progression + ]) + + if ds3_world.options.smooth_upgraded_weapons: + upgraded_weapons = [ + location.item + for location in multiworld.get_filled_locations() + if location.item.player == ds3_world.player + and location.item.level and location.item.level > 0 + and location.item.classification != ItemClassification.progression + ] + upgraded_weapons.sort(key=lambda item: item.level) + smooth_items(upgraded_weapons) def _shuffle(self, seq: Sequence) -> List: """Returns a shuffled copy of a sequence.""" From b9642a482f67f2358f13d2306a90673fc4f8fd9a Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Thu, 26 Dec 2024 17:04:21 -0500 Subject: [PATCH 054/144] KH2: Using fast_fill instead of fill_restrictive (#4227) --- worlds/kh2/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/worlds/kh2/__init__.py b/worlds/kh2/__init__.py index 2809460aed6a..59c77627eebe 100644 --- a/worlds/kh2/__init__.py +++ b/worlds/kh2/__init__.py @@ -2,7 +2,7 @@ from typing import List from BaseClasses import Tutorial, ItemClassification -from Fill import fill_restrictive +from Fill import fast_fill from worlds.LauncherComponents import Component, components, Type, launch_subprocess from worlds.AutoWorld import World, WebWorld from .Items import * @@ -287,7 +287,7 @@ def generate_early(self) -> None: def pre_fill(self): """ - Plandoing Events and Fill_Restrictive for donald,goofy and sora + Plandoing Events and Fast_Fill for donald,goofy and sora """ self.donald_pre_fill() self.goofy_pre_fill() @@ -431,9 +431,10 @@ def keyblade_pre_fill(self): Fills keyblade slots with abilities determined on player's setting """ keyblade_locations = [self.multiworld.get_location(location, self.player) for location in Keyblade_Slots.keys()] - state = self.multiworld.get_all_state(False) keyblade_ability_pool_copy = self.keyblade_ability_pool.copy() - fill_restrictive(self.multiworld, state, keyblade_locations, keyblade_ability_pool_copy, True, True, allow_excluded=True) + fast_fill(self.multiworld, keyblade_ability_pool_copy, keyblade_locations) + for location in keyblade_locations: + location.locked = True def starting_invo_verify(self): """ From 218f28912e0e120e4cf91a63aba627e91cc451c5 Mon Sep 17 00:00:00 2001 From: BadMagic100 Date: Fri, 27 Dec 2024 12:04:02 -0800 Subject: [PATCH 055/144] Core: Generic Entrance Rando (#2883) * Initial implementation of Generic ER * Move ERType to Entrance.Type, fix typing imports * updates based on testing (read: flailing) * Updates from feedback * Various bug fixes in ERCollectionState * Use deque instead of queue.Queue * Allow partial entrances in collection state earlier, doc improvements * Prevent early loops in region graph, improve reusability of ER stage code * Typos, grammar, PEP8, and style "fixes" * use RuntimeError instead of bare Exceptions * return tuples from connect since it's slightly faster for our purposes * move the shuffle to the beginning of find_pairing * do er_state placements within pairing lookups to remove code duplication * requested adjustments * Add some temporary performance logging * Use CollectionState to track available exits and placed regions * Add a method to automatically disconnect entrances in a coupled-compliant way Update docs and cleanup todos * Make find_placeable_exits deterministic by sorting blocked_connections set * Move EntranceType out of Entrance * Handle minimal accessibility, autodetect regions, and improvements to disconnect * Add on_connect callback to react to succeeded entrance placements * Relax island-prevention constraints after a successful run on minimal accessibility; better error message on failure * First set of unit tests for generic ER * Change on_connect to send lists, add unit tests for EntranceLookup * Fix duplicated location names in tests * Update tests after merge * Address review feedback, start docs with diagrams * Fix rendering of hidden nodes in ER doc * Move most docstring content into a docs article * Clarify when randomize_entrances can be called safely * Address review feedback * Apply suggestions from code review Co-authored-by: Aaron Wagener * Docs on ERPlacementState, add coupled/uncoupled handling to deadend detection * Documentation clarifications * Update groups to allow any hashable * Restrict groups from hashable to int * Implement speculative sweeping in stage 1, address misc review comments * Clean unused imports in BaseClasses.py * Restrictive region/speculative sweep test * sweep_for_events->advancement * Remove redundant __str__ Co-authored-by: Doug Hoskisson * Allow partial entrances in auto indirect condition sweep * Treat regions needed for logic as non-dead-end regardless of if they have exits, flip order of stage 3 and 4 to ensure there are enough exits for the dead ends * Typing fixes suggested by mypy * Remove erroneous newline Not sure why the merge conflict editor is different and worse than the normal editor. Crazy * Use modern typing for ER * Enforce the use of explicit indirect conditions * Improve doc on required indirect conditions --------- Co-authored-by: qwint Co-authored-by: alwaysintreble Co-authored-by: Doug Hoskisson --- BaseClasses.py | 66 +++- docs/entrance randomization.md | 430 ++++++++++++++++++++++++++ entrance_rando.py | 447 ++++++++++++++++++++++++++++ test/general/test_entrance_rando.py | 387 ++++++++++++++++++++++++ 4 files changed, 1324 insertions(+), 6 deletions(-) create mode 100644 docs/entrance randomization.md create mode 100644 entrance_rando.py create mode 100644 test/general/test_entrance_rando.py diff --git a/BaseClasses.py b/BaseClasses.py index e5c187b9117f..e19ba5f7772e 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -19,6 +19,7 @@ import Utils if TYPE_CHECKING: + from entrance_rando import ERPlacementState from worlds import AutoWorld @@ -426,12 +427,12 @@ def get_entrance(self, entrance_name: str, player: int) -> Entrance: def get_location(self, location_name: str, player: int) -> Location: return self.regions.location_cache[player][location_name] - def get_all_state(self, use_cache: bool) -> CollectionState: + def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState: cached = getattr(self, "_all_state", None) if use_cache and cached: return cached.copy() - ret = CollectionState(self) + ret = CollectionState(self, allow_partial_entrances) for item in self.itempool: self.worlds[item.player].collect(ret, item) @@ -717,10 +718,11 @@ class CollectionState(): path: Dict[Union[Region, Entrance], PathValue] locations_checked: Set[Location] stale: Dict[int, bool] + allow_partial_entrances: bool additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = [] additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] - def __init__(self, parent: MultiWorld): + def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): self.prog_items = {player: Counter() for player in parent.get_all_ids()} self.multiworld = parent self.reachable_regions = {player: set() for player in parent.get_all_ids()} @@ -729,6 +731,7 @@ def __init__(self, parent: MultiWorld): self.path = {} self.locations_checked = set() self.stale = {player: True for player in parent.get_all_ids()} + self.allow_partial_entrances = allow_partial_entrances for function in self.additional_init_functions: function(self, parent) for items in parent.precollected_items.values(): @@ -763,6 +766,8 @@ def _update_reachable_regions_explicit_indirect_conditions(self, player: int, qu if new_region in reachable_regions: blocked_connections.remove(connection) elif connection.can_reach(self): + if self.allow_partial_entrances and not new_region: + continue assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region" reachable_regions.add(new_region) blocked_connections.remove(connection) @@ -788,7 +793,9 @@ def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: if new_region in reachable_regions: blocked_connections.remove(connection) elif connection.can_reach(self): - assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" + if self.allow_partial_entrances and not new_region: + continue + assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region" reachable_regions.add(new_region) blocked_connections.remove(connection) blocked_connections.update(new_region.exits) @@ -808,6 +815,7 @@ def copy(self) -> CollectionState: ret.advancements = self.advancements.copy() ret.path = self.path.copy() ret.locations_checked = self.locations_checked.copy() + ret.allow_partial_entrances = self.allow_partial_entrances for function in self.additional_copy_functions: ret = function(self, ret) return ret @@ -972,6 +980,11 @@ def remove(self, item: Item): self.stale[item.player] = True +class EntranceType(IntEnum): + ONE_WAY = 1 + TWO_WAY = 2 + + class Entrance: access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) hide_path: bool = False @@ -979,19 +992,24 @@ class Entrance: name: str parent_region: Optional[Region] connected_region: Optional[Region] = None + randomization_group: int + randomization_type: EntranceType # LttP specific, TODO: should make a LttPEntrance addresses = None target = None - def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None: + def __init__(self, player: int, name: str = "", parent: Optional[Region] = None, + randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None: self.name = name self.parent_region = parent self.player = player + self.randomization_group = randomization_group + self.randomization_type = randomization_type def can_reach(self, state: CollectionState) -> bool: assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region" if self.parent_region.can_reach(state) and self.access_rule(state): - if not self.hide_path and not self in state.path: + if not self.hide_path and self not in state.path: state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) return True @@ -1003,6 +1021,32 @@ def connect(self, region: Region, addresses: Any = None, target: Any = None) -> self.addresses = addresses region.entrances.append(self) + def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool: + """ + Determines whether this is a valid source transition, that is, whether the entrance + randomizer is allowed to pair it to place any other regions. By default, this is the + same as a reachability check, but can be modified by Entrance implementations to add + other restrictions based on the placement state. + + :param er_state: The current (partial) state of the ongoing entrance randomization + """ + return self.can_reach(er_state.collection_state) + + def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool: + """ + Determines whether a given Entrance is a valid target transition, that is, whether + the entrance randomizer is allowed to pair this Entrance to that Entrance. By default, + only allows connection between entrances of the same type (one ways only go to one ways, + two ways always go to two ways) and prevents connecting an exit to itself in coupled mode. + + :param other: The proposed Entrance to connect to + :param dead_end: Whether the other entrance considered a dead end by Entrance randomization + :param er_state: The current (partial) state of the ongoing entrance randomization + """ + # the implementation of coupled causes issues for self-loops since the reverse entrance will be the + # same as the forward entrance. In uncoupled they are ok. + return self.randomization_type == other.randomization_type and (not er_state.coupled or self.name != other.name) + def __repr__(self): multiworld = self.parent_region.multiworld if self.parent_region else None return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' @@ -1152,6 +1196,16 @@ def create_exit(self, name: str) -> Entrance: self.exits.append(exit_) return exit_ + def create_er_target(self, name: str) -> Entrance: + """ + Creates and returns an Entrance object as an entrance to this region + + :param name: name of the Entrance being created + """ + entrance = self.entrance_type(self.player, name) + entrance.connect(self) + return entrance + def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]: """ diff --git a/docs/entrance randomization.md b/docs/entrance randomization.md new file mode 100644 index 000000000000..9e3e281bcc31 --- /dev/null +++ b/docs/entrance randomization.md @@ -0,0 +1,430 @@ +# Entrance Randomization + +This document discusses the API and underlying implementation of the generic entrance randomization algorithm +exposed in [entrance_rando.py](/entrance_rando.py). Throughout the doc, entrance randomization is frequently abbreviated +as "ER." + +This doc assumes familiarity with Archipelago's graph logic model. If you don't have a solid understanding of how +regions work, you should start there. + +## Entrance randomization concepts + +### Terminology + +Some important terminology to understand when reading this doc and working with ER is listed below. + +* Entrance rando - sometimes called "room rando," "transition rando," "door rando," or similar, + this is a game mode in which the game map itself is randomized. + In Archipelago, these things are often represented as `Entrance`s in the region graph, so we call it Entrance rando. +* Entrances and exits - entrances are ways into your region, exits are ways out of the region. In code, they are both + represented as `Entrance` objects. In this doc, the terms "entrances" and "exits" will be used in this sense; the + `Entrance` class will always be referenced in a code block with an uppercase E. +* Dead end - a connected group of regions which can never help ER progress. This means that it: + * Is not in any indirect conditions/access rules. + * Has no plando'd or otherwise preplaced progression items, including events. + * Has no randomized exits. +* One way transition - a transition that, in the game, is not safe to reverse through (for example, in Hollow Knight, + some transitions are inaccessible backwards in vanilla and would put you out of bounds). One way transitions are + paired together during randomization to prevent such unsafe game states. Most transitions are not one way. + +### Basic randomization strategy + +The Generic ER algorithm works by using the logic structures you are already familiar with. To give a basic example, +let's assume a toy world is defined with the vanilla region graph modeled below. In this diagram, the smaller boxes +represent regions while the larger boxes represent scenes. Scenes are not an Archipelago concept, the grouping is +purely illustrative. + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph startingRoom [Starting Room] + S[Starting Room Right Door] + end + subgraph sceneB [Scene B] + BR1[Scene B Right Door] + end + subgraph sceneA [Scene A] + AL1[Scene A Lower Left Door] <--> AR1[Scene A Right Door] + AL2[Scene A Upper Left Door] <--> AR1 + end + subgraph sceneC [Scene C] + CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] + CL1 <--> CR2[Scene C Lower Right Door] + end + subgraph sceneD [Scene D] + DL1[Scene D Left Door] <--> DR1[Scene D Right Door] + end + subgraph endingRoom [Ending Room] + EL1[Ending Room Upper Left Door] <--> Victory + EL2[Ending Room Lower Left Door] <--> Victory + end + Menu --> S + S <--> AL2 + BR1 <--> AL1 + AR1 <--> CL1 + CR1 <--> DL1 + DR1 <--> EL1 + CR2 <--> EL2 + + classDef hidden display:none; +``` + +First, the world begins by splitting the `Entrance`s which should be randomized. This is essentially all that has to be +done on the world side; calling the `randomize_entrances` function will do the rest, using your region definitions and +logic to generate a valid world layout by connecting the partially connected edges you've defined. After you have done +that, your region graph might look something like the following diagram. Note how each randomizable entrance/exit pair +(represented as a bidirectional arrow) is disconnected on one end. + +> [!NOTE] +> It is required to use explicit indirect conditions when using Generic ER. Without this restriction, +> Generic ER would have no way to correctly determine that a region may be required in logic, +> leading to significantly higher failure rates due to mis-categorized regions. + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph startingRoom [Starting Room] + S[Starting Room Right Door] + end + subgraph sceneA [Scene A] + AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door] + AL2[Scene A Lower Left Door] <--> AR1 + end + subgraph sceneB [Scene B] + BR1[Scene B Right Door] + end + subgraph sceneC [Scene C] + CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] + CL1 <--> CR2[Scene C Lower Right Door] + end + subgraph sceneD [Scene D] + DL1[Scene D Left Door] <--> DR1[Scene D Right Door] + end + subgraph endingRoom [Ending Room] + EL1[Ending Room Upper Left Door] <--> Victory + EL2[Ending Room Lower Left Door] <--> Victory + end + Menu --> S + S <--> T1:::hidden + T2:::hidden <--> AL1 + T3:::hidden <--> AL2 + AR1 <--> T5:::hidden + BR1 <--> T4:::hidden + T6:::hidden <--> CL1 + CR1 <--> T7:::hidden + CR2 <--> T11:::hidden + T8:::hidden <--> DL1 + DR1 <--> T9:::hidden + T10:::hidden <--> EL1 + T12:::hidden <--> EL2 + + classDef hidden display:none; +``` + +From here, you can call the `randomize_entrances` function and Archipelago takes over. Starting from the Menu region, +the algorithm will sweep out to find eligible region exits to randomize. It will then select an eligible target entrance +and connect them, prioritizing giving access to unvisited regions first until all regions are placed. Once the exit has +been connected to the new region, placeholder entrances are deleted. This process is visualized in the diagram below +with the newly connected edge highlighted in red. + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph startingRoom [Starting Room] + S[Starting Room Right Door] + end + subgraph sceneA [Scene A] + AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door] + AL2[Scene A Lower Left Door] <--> AR1 + end + subgraph sceneB [Scene B] + BR1[Scene B Right Door] + end + subgraph sceneC [Scene C] + CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] + CL1 <--> CR2[Scene C Lower Right Door] + end + subgraph sceneD [Scene D] + DL1[Scene D Left Door] <--> DR1[Scene D Right Door] + end + subgraph endingRoom [Ending Room] + EL1[Ending Room Upper Left Door] <--> Victory + EL2[Ending Room Lower Left Door] <--> Victory + end + Menu --> S + S <--> CL1 + T2:::hidden <--> AL1 + T3:::hidden <--> AL2 + AR1 <--> T5:::hidden + BR1 <--> T4:::hidden + CR1 <--> T7:::hidden + CR2 <--> T11:::hidden + T8:::hidden <--> DL1 + DR1 <--> T9:::hidden + T10:::hidden <--> EL1 + T12:::hidden <--> EL2 + + classDef hidden display:none; + linkStyle 8 stroke:red,stroke-width:5px; +``` + +This process is then repeated until all disconnected `Entrance`s have been connected or deleted, eventually resulting +in a randomized region layout. + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph startingRoom [Starting Room] + S[Starting Room Right Door] + end + subgraph sceneA [Scene A] + AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door] + AL2[Scene A Lower Left Door] <--> AR1 + end + subgraph sceneB [Scene B] + BR1[Scene B Right Door] + end + subgraph sceneC [Scene C] + CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] + CL1 <--> CR2[Scene C Lower Right Door] + end + subgraph sceneD [Scene D] + DL1[Scene D Left Door] <--> DR1[Scene D Right Door] + end + subgraph endingRoom [Ending Room] + EL1[Ending Room Upper Left Door] <--> Victory + EL2[Ending Room Lower Left Door] <--> Victory + end + Menu --> S + S <--> CL1 + AR1 <--> DL1 + BR1 <--> EL2 + CR1 <--> EL1 + CR2 <--> AL1 + DR1 <--> AL2 + + classDef hidden display:none; +``` + +#### ER and minimal accessibility + +In general, even on minimal accessibility, ER will prefer to provide access to as many regions as possible. This is for +2 reasons: +1. Generally, having items spread across the world is going to be a more fun/engaging experience for players than + severely restricting their map. Imagine an ER arrangement with just the start region, the goal region, and exactly + enough locations in between them to get the goal - this may be the intuitive behavior of minimal, or even the desired + behavior in some cases, but it is not a particularly interesting randomizer. +2. Giving access to more of the world will give item fill a higher chance to succeed. + +However, ER will cull unreachable regions and exits if necessary to save the generation of a beaten minimal. + +## Usage + +### Defining entrances to be randomized + +The first step to using generic ER is defining entrances to be randomized. In order to do this, you will need to +leave partially disconnected exits without a `target_region` and partially disconnected entrances without a +`parent_region`. You can do this either by hand using `region.create_exit` and `region.create_er_target`, or you can +create your vanilla region graph and then use `disconnect_entrance_for_randomization` to split the desired edges. +If you're not sure which to use, prefer the latter approach as it will automatically satisfy the requirements for +coupled randomization (discussed in more depth later). + +> [!TIP] +> It's recommended to give your `Entrance`s non-default names when creating them. The default naming scheme is +> `f"{parent_region} -> {target_region}"` which is generally not helpful in an entrance rando context - after all, +> the target region will not be the same as vanilla and regions are often not user-facing anyway. Instead consider names +> that describe the location of the exit, such as "Starting Room Right Door." + +When creating your `Entrance`s you should also set the randomization type and group. One-way `Entrance`s represent +transitions which are impossible to traverse in reverse. All other transitions are two-ways. To ensure that all +transitions can be accessed in the game, one-ways are only randomized with other one-ways and two-ways are only +randomized with other two-ways. You can set whether an `Entrance` is one-way or two-way using the `randomization_type` +attribute. + +`Entrance`s can also set the `randomization_group` attribute to allow for grouping during randomization. This can be +any integer you define and may be based on player options. Some possible use cases for grouping include: +* Directional matching - only match leftward-facing transitions to rightward-facing ones +* Terrain matching - only match water transitions to water transitions and land transitions to land transitions +* Dungeon shuffle - only shuffle entrances within a dungeon/area with each other +* Combinations of the above + +By default, all `Entrance`s are placed in the group 0. An entrance can only be a member of one group, but a given group +may connect to many other groups. + +### Calling generic ER + +Once you have defined all your entrances and exits and connected the Menu region to your region graph, you can call +`randomize_entrances` to perform randomization. + +#### Coupled and uncoupled modes + +In coupled randomization, an entrance placed from A to B guarantees that the reverse placement B to A also exists +(assuming that A and B are both two-way doors). Uncoupled randomization does not make this guarantee. + +When using coupled mode, there are some requirements for how placeholder ER targets for two-ways are named. +`disconnect_entrance_for_randomization` will handle this for you. However, if you opt to create your ER targets and +exits by hand, you will need to ensure that ER targets into a region are named the same as the exit they correspond to. +This allows the randomizer to find and connect the reverse pairing after the first pairing is completed. See the diagram +below for an example of incorrect and correct naming. + +Incorrect target naming: + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph a [" "] + direction TB + target1 + target2 + end + subgraph b [" "] + direction TB + Region + end + Region["Room1"] -->|Room1 Right Door| target1:::hidden + Region --- target2:::hidden -->|Room2 Left Door| Region + + linkStyle 1 stroke:none; + classDef hidden display:none; + style a display:none; + style b display:none; +``` + +Correct target naming: + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph a [" "] + direction TB + target1 + target2 + end + subgraph b [" "] + direction TB + Region + end + Region["Room1"] -->|Room1 Right Door| target1:::hidden + Region --- target2:::hidden -->|Room1 Right Door| Region + + linkStyle 1 stroke:none; + classDef hidden display:none; + style a display:none; + style b display:none; +``` + +#### Implementing grouping + +When you created your entrances, you defined the group each entrance belongs to. Now you will have to define how groups +should connect with each other. This is done with the `target_group_lookup` and `preserve_group_order` parameters. +There is also a convenience function `bake_target_group_lookup` which can help to prepare group lookups when more +complex group mapping logic is needed. Some recipes for `target_group_lookup` are presented here. + +For the recipes below, assume the following groups (if the syntax used here is unfamiliar to you, "bit masking" and +"bitwise operators" would be the terms to search for): +```python +class Groups(IntEnum): + # Directions + LEFT = 1 + RIGHT = 2 + TOP = 3 + BOTTOM = 4 + DOOR = 5 + # Areas + FIELD = 1 << 3 + CAVE = 2 << 3 + MOUNTAIN = 3 << 3 + # Bitmasks + DIRECTION_MASK = FIELD - 1 + AREA_MASK = ~0 << 3 +``` + +Directional matching: +```python +direction_matching_group_lookup = { + # with preserve_group_order = False, pair a left transition to either a right transition or door randomly + # with preserve_group_order = True, pair a left transition to a right transition, or else a door if no + # viable right transitions remain + Groups.LEFT: [Groups.RIGHT, Groups.DOOR], + # ... +} +``` + +Terrain matching or dungeon shuffle: +```python +def randomize_within_same_group(group: int) -> List[int]: + return [group] +identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group) +``` + +Directional + area shuffle: +```python +def get_target_groups(group: int) -> List[int]: + # example group: LEFT | CAVE + # example result: [RIGHT | CAVE, DOOR | CAVE] + direction = group & Groups.DIRECTION_MASK + area = group & Groups.AREA_MASK + return [pair_direction | area for pair_direction in direction_matching_group_lookup[direction]] +target_group_lookup = bake_target_group_lookup(world, get_target_groups) +``` + +#### When to call `randomize_entrances` + +The short answer is that you will almost always want to do ER in `pre_fill`. For more information why, continue reading. + +ER begins by collecting the entire item pool and then uses your access rules to try and prevent some kinds of failures. +This means 2 things about when you can call ER: +1. You must supply your item pool before calling ER, or call ER before setting any rules which require items. +2. If you have rules dependent on anything other than items (e.g. `Entrance`s or events), you must set your rules + and create your events before you call ER if you want to guarantee a correct output. + +If the conditions above are met, you could theoretically do ER as early as `create_regions`. However, plando is also +a consideration. Since item plando happens between `set_rules` and `pre_fill` and modifies the item pool, doing ER +in `pre_fill` is the only way to account for placements made by item plando, otherwise you risk impossible seeds or +generation failures. Obviously, if your world implements entrance plando, you will likely want to do that before ER as +well. + +#### Informing your client about randomized entrances + +`randomize_entrances` returns the completed `ERPlacementState`. The `pairings` attribute contains a list of the +created placements by name which can be used to populate slot data. + +### Imposing custom constraints on randomization + +Generic ER is, as the name implies, generic! That means that your world may have some use case which is not covered by +the ER implementation. To solve this, you can create a custom `Entrance` class which provides custom implementations +for `is_valid_source_transition` and `can_connect_to`. These allow arbitrary constraints to be implemented on +randomization, for instance helping to prevent restrictive sphere 1s or ensuring a maximum distance from a "hub" region. + +> [!IMPORTANT] +> When implementing these functions, make sure to use `super().is_valid_source_transition` and `super().can_connect_to` +> as part of your implementation. Otherwise ER may behave unexpectedly. + +## Implementation details + +This section is a medium-level explainer of the implementation of ER for those who don't want to decipher the code. +However, a basic understanding of the mechanics of `fill_restrictive` will be helpful as many of the underlying +algorithms are shared + +ER uses a forward fill approach to create the region layout. First, ER collects `all_state` and performs a region sweep +from Menu, similar to fill. ER then proceeds in stages to complete the randomization: +1. Attempt to connect all non-dead-end regions, prioritizing access to unseen regions so there will always be new exits + to pair off. +2. Attempt to connect all dead-end regions, so that all regions will be placed +3. Connect all remaining dangling edges now that all regions are placed. + 1. Connect any other dead end entrances (e.g. second entrances to the same dead end regions). + 2. Connect all remaining non-dead-ends amongst each other. + +The process for each connection will do the following: +1. Select a randomizable exit of a reachable region which is a valid source transition. +2. Get its group and check `target_group_lookup` to determine which groups are valid targets. +3. Look up ER targets from those groups and find one which is valid according to `can_connect_to` +4. Connect the source exit to the target's target_region and delete the target. + * In stage 1, before placing the last valid source transition, an additional speculative sweep is performed to ensure + that there will be an available exit after the placement so randomization can continue. +5. If it's coupled mode, find the reverse exit and target by name and connect them as well. +6. Sweep to update reachable regions. +7. Call the `on_connect` callback. + +This process repeats until the stage is complete, no valid source transition is found, or no valid target transition is +found for any source transition. Unlike fill, there is no attempt made to save a failed randomization. \ No newline at end of file diff --git a/entrance_rando.py b/entrance_rando.py new file mode 100644 index 000000000000..5aa16fa0bb06 --- /dev/null +++ b/entrance_rando.py @@ -0,0 +1,447 @@ +import itertools +import logging +import random +import time +from collections import deque +from collections.abc import Callable, Iterable + +from BaseClasses import CollectionState, Entrance, Region, EntranceType +from Options import Accessibility +from worlds.AutoWorld import World + + +class EntranceRandomizationError(RuntimeError): + pass + + +class EntranceLookup: + class GroupLookup: + _lookup: dict[int, list[Entrance]] + + def __init__(self): + self._lookup = {} + + def __len__(self): + return sum(map(len, self._lookup.values())) + + def __bool__(self): + return bool(self._lookup) + + def __getitem__(self, item: int) -> list[Entrance]: + return self._lookup.get(item, []) + + def __iter__(self): + return itertools.chain.from_iterable(self._lookup.values()) + + def __repr__(self): + return str(self._lookup) + + def add(self, entrance: Entrance) -> None: + self._lookup.setdefault(entrance.randomization_group, []).append(entrance) + + def remove(self, entrance: Entrance) -> None: + group = self._lookup[entrance.randomization_group] + group.remove(entrance) + if not group: + del self._lookup[entrance.randomization_group] + + dead_ends: GroupLookup + others: GroupLookup + _random: random.Random + _expands_graph_cache: dict[Entrance, bool] + _coupled: bool + + def __init__(self, rng: random.Random, coupled: bool): + self.dead_ends = EntranceLookup.GroupLookup() + self.others = EntranceLookup.GroupLookup() + self._random = rng + self._expands_graph_cache = {} + self._coupled = coupled + + def _can_expand_graph(self, entrance: Entrance) -> bool: + """ + Checks whether an entrance is able to expand the region graph, either by + providing access to randomizable exits or by granting access to items or + regions used in logic conditions. + + :param entrance: A randomizable (no parent) region entrance + """ + # we've seen this, return cached result + if entrance in self._expands_graph_cache: + return self._expands_graph_cache[entrance] + + visited = set() + q: deque[Region] = deque() + q.append(entrance.connected_region) + + while q: + region = q.popleft() + visited.add(region) + + # check if the region itself is progression + if region in region.multiworld.indirect_connections: + self._expands_graph_cache[entrance] = True + return True + + # check if any placed locations are progression + for loc in region.locations: + if loc.advancement: + self._expands_graph_cache[entrance] = True + return True + + # check if there is a randomized exit out (expands the graph directly) or else search any connected + # regions to see if they are/have progression + for exit_ in region.exits: + # randomizable exits which are not reverse of the incoming entrance. + # uncoupled mode is an exception because in this case going back in the door you just came in could + # actually lead somewhere new + if not exit_.connected_region and (not self._coupled or exit_.name != entrance.name): + self._expands_graph_cache[entrance] = True + return True + elif exit_.connected_region and exit_.connected_region not in visited: + q.append(exit_.connected_region) + + self._expands_graph_cache[entrance] = False + return False + + def add(self, entrance: Entrance) -> None: + lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends + lookup.add(entrance) + + def remove(self, entrance: Entrance) -> None: + lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends + lookup.remove(entrance) + + def get_targets( + self, + groups: Iterable[int], + dead_end: bool, + preserve_group_order: bool + ) -> Iterable[Entrance]: + + lookup = self.dead_ends if dead_end else self.others + if preserve_group_order: + for group in groups: + self._random.shuffle(lookup[group]) + ret = [entrance for group in groups for entrance in lookup[group]] + else: + ret = [entrance for group in groups for entrance in lookup[group]] + self._random.shuffle(ret) + return ret + + def __len__(self): + return len(self.dead_ends) + len(self.others) + + +class ERPlacementState: + """The state of an ongoing or completed entrance randomization""" + placements: list[Entrance] + """The list of randomized Entrance objects which have been connected successfully""" + pairings: list[tuple[str, str]] + """A list of pairings of connected entrance names, of the form (source_exit, target_entrance)""" + world: World + """The world which is having its entrances randomized""" + collection_state: CollectionState + """The CollectionState backing the entrance randomization logic""" + coupled: bool + """Whether entrance randomization is operating in coupled mode""" + + def __init__(self, world: World, coupled: bool): + self.placements = [] + self.pairings = [] + self.world = world + self.coupled = coupled + self.collection_state = world.multiworld.get_all_state(False, True) + + @property + def placed_regions(self) -> set[Region]: + return self.collection_state.reachable_regions[self.world.player] + + def find_placeable_exits(self, check_validity: bool) -> list[Entrance]: + if check_validity: + blocked_connections = self.collection_state.blocked_connections[self.world.player] + blocked_connections = sorted(blocked_connections, key=lambda x: x.name) + placeable_randomized_exits = [connection for connection in blocked_connections + if not connection.connected_region + and connection.is_valid_source_transition(self)] + else: + # this is on a beaten minimal attempt, so any exit anywhere is fair game + placeable_randomized_exits = [ex for region in self.world.multiworld.get_regions(self.world.player) + for ex in region.exits if not ex.connected_region] + self.world.random.shuffle(placeable_randomized_exits) + return placeable_randomized_exits + + def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> None: + target_region = target_entrance.connected_region + + target_region.entrances.remove(target_entrance) + source_exit.connect(target_region) + + self.collection_state.stale[self.world.player] = True + self.placements.append(source_exit) + self.pairings.append((source_exit.name, target_entrance.name)) + + def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance) -> bool: + copied_state = self.collection_state.copy() + # simulated connection. A real connection is unsafe because the region graph is shallow-copied and would + # propagate back to the real multiworld. + copied_state.reachable_regions[self.world.player].add(target_entrance.connected_region) + copied_state.blocked_connections[self.world.player].remove(source_exit) + copied_state.blocked_connections[self.world.player].update(target_entrance.connected_region.exits) + copied_state.update_reachable_regions(self.world.player) + copied_state.sweep_for_advancements() + # test that at there are newly reachable randomized exits that are ACTUALLY reachable + available_randomized_exits = copied_state.blocked_connections[self.world.player] + for _exit in available_randomized_exits: + if _exit.connected_region: + continue + # ignore the source exit, and, if coupled, the reverse exit. They're not actually new + if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name): + continue + # technically this should be is_valid_source_transition, but that may rely on side effects from + # on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would + # not want them to persist). can_reach is a close enough approximation most of the time. + if _exit.can_reach(copied_state): + return True + return False + + def connect( + self, + source_exit: Entrance, + target_entrance: Entrance + ) -> tuple[list[Entrance], list[Entrance]]: + """ + Connects a source exit to a target entrance in the graph, accounting for coupling + + :returns: The newly placed exits and the dummy entrance(s) which were removed from the graph + """ + source_region = source_exit.parent_region + target_region = target_entrance.connected_region + + self._connect_one_way(source_exit, target_entrance) + # if we're doing coupled randomization place the reverse transition as well. + if self.coupled and source_exit.randomization_type == EntranceType.TWO_WAY: + for reverse_entrance in source_region.entrances: + if reverse_entrance.name == source_exit.name: + if reverse_entrance.parent_region: + raise EntranceRandomizationError( + f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} " + f"because the reverse entrance is already parented to " + f"{reverse_entrance.parent_region.name}.") + break + else: + raise EntranceRandomizationError(f"Two way exit {source_exit.name} had no corresponding entrance in " + f"{source_exit.parent_region.name}") + for reverse_exit in target_region.exits: + if reverse_exit.name == target_entrance.name: + if reverse_exit.connected_region: + raise EntranceRandomizationError( + f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} " + f"because the reverse exit is already connected to " + f"{reverse_exit.connected_region.name}.") + break + else: + raise EntranceRandomizationError(f"Two way entrance {target_entrance.name} had no corresponding exit " + f"in {target_region.name}.") + self._connect_one_way(reverse_exit, reverse_entrance) + return [source_exit, reverse_exit], [target_entrance, reverse_entrance] + return [source_exit], [target_entrance] + + +def bake_target_group_lookup(world: World, get_target_groups: Callable[[int], list[int]]) \ + -> dict[int, list[int]]: + """ + Applies a transformation to all known entrance groups on randomizable exists to build a group lookup table. + + :param world: Your World instance + :param get_target_groups: Function to call that returns the groups that a specific group type is allowed to + connect to + """ + unique_groups = { entrance.randomization_group for entrance in world.multiworld.get_entrances(world.player) + if entrance.parent_region and not entrance.connected_region } + return { group: get_target_groups(group) for group in unique_groups } + + +def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None) -> None: + """ + Given an entrance in a "vanilla" region graph, splits that entrance to prepare it for randomization + in randomize_entrances. This should be done after setting the type and group of the entrance. + + :param entrance: The entrance which will be disconnected in preparation for randomization. + :param target_group: The group to assign to the created ER target. If not specified, the group from + the original entrance will be copied. + """ + child_region = entrance.connected_region + parent_region = entrance.parent_region + + # disconnect the edge + child_region.entrances.remove(entrance) + entrance.connected_region = None + + # create the needed ER target + if entrance.randomization_type == EntranceType.TWO_WAY: + # for 2-ways, create a target in the parent region with a matching name to support coupling. + # targets in the child region will be created when the other direction edge is disconnected + target = parent_region.create_er_target(entrance.name) + else: + # for 1-ways, the child region needs a target and coupling/naming is not a concern + target = child_region.create_er_target(child_region.name) + target.randomization_type = entrance.randomization_type + target.randomization_group = target_group or entrance.randomization_group + + +def randomize_entrances( + world: World, + coupled: bool, + target_group_lookup: dict[int, list[int]], + preserve_group_order: bool = False, + er_targets: list[Entrance] | None = None, + exits: list[Entrance] | None = None, + on_connect: Callable[[ERPlacementState, list[Entrance]], None] | None = None +) -> ERPlacementState: + """ + Randomizes Entrances for a single world in the multiworld. + + :param world: Your World instance + :param coupled: Whether connected entrances should be coupled to go in both directions + :param target_group_lookup: Map from each group to a list of the groups that it can be connect to. Every group + used on an exit must be provided and must map to at least one other group. The default + group is 0. + :param preserve_group_order: Whether the order of groupings should be preserved for the returned target_groups + :param er_targets: The list of ER targets (Entrance objects with no parent region) to use for randomization. + Remember to be deterministic! If not provided, automatically discovers all valid targets + in your world. + :param exits: The list of exits (Entrance objects with no target region) to use for randomization. + Remember to be deterministic! If not provided, automatically discovers all valid exits in your world. + :param on_connect: A callback function which allows specifying side effects after a placement is completed + successfully and the underlying collection state has been updated. + """ + if not world.explicit_indirect_conditions: + raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order " + + "to correctly analyze whether dead end regions can be required in logic.") + + start_time = time.perf_counter() + er_state = ERPlacementState(world, coupled) + entrance_lookup = EntranceLookup(world.random, coupled) + # similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility + perform_validity_check = True + + def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None: + placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance) + # remove the placed targets from consideration + for entrance in removed_entrances: + entrance_lookup.remove(entrance) + # propagate new connections + er_state.collection_state.update_reachable_regions(world.player) + er_state.collection_state.sweep_for_advancements() + if on_connect: + on_connect(er_state, placed_exits) + + def find_pairing(dead_end: bool, require_new_exits: bool) -> bool: + nonlocal perform_validity_check + placeable_exits = er_state.find_placeable_exits(perform_validity_check) + for source_exit in placeable_exits: + target_groups = target_group_lookup[source_exit.randomization_group] + for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): + # when requiring new exits, ideally we would like to make it so that every placement increases + # (or keeps the same number of) reachable exits. The goal is to continue to expand the search space + # so that we do not crash. In the interest of performance and bias reduction, generally, just checking + # that we are going to a new region is a good approximation. however, we should take extra care on the + # very last exit and check whatever exits we open up are functionally accessible. + # this requirement can be ignored on a beaten minimal, islands are no issue there. + exit_requirement_satisfied = (not perform_validity_check or not require_new_exits + or target_entrance.connected_region not in er_state.placed_regions) + needs_speculative_sweep = (not dead_end and require_new_exits and perform_validity_check + and len(placeable_exits) == 1) + if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state): + if (needs_speculative_sweep + and not er_state.test_speculative_connection(source_exit, target_entrance)): + continue + do_placement(source_exit, target_entrance) + return True + else: + # no source exits had any valid target so this stage is deadlocked. retries may be implemented if early + # deadlocking is a frequent issue. + lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others + + # if we're in a stage where we're trying to get to new regions, we could also enter this + # branch in a success state (when all regions of the preferred type have been placed, but there are still + # additional unplaced entrances into those regions) + if require_new_exits: + if all(e.connected_region in er_state.placed_regions for e in lookup): + return False + + # if we're on minimal accessibility and can guarantee the game is beatable, + # we can prevent a failure by bypassing future validity checks. this check may be + # expensive; fortunately we only have to do it once + if perform_validity_check and world.options.accessibility == Accessibility.option_minimal \ + and world.multiworld.has_beaten_game(er_state.collection_state, world.player): + # ensure that we have enough locations to place our progression + accessible_location_count = 0 + prog_item_count = sum(er_state.collection_state.prog_items[world.player].values()) + # short-circuit location checking in this case + if prog_item_count == 0: + return True + for region in er_state.placed_regions: + for loc in region.locations: + if loc.can_reach(er_state.collection_state): + accessible_location_count += 1 + if accessible_location_count >= prog_item_count: + perform_validity_check = False + # pretend that this was successful to retry the current stage + return True + + unplaced_entrances = [entrance for region in world.multiworld.get_regions(world.player) + for entrance in region.entrances if not entrance.parent_region] + unplaced_exits = [exit_ for region in world.multiworld.get_regions(world.player) + for exit_ in region.exits if not exit_.connected_region] + entrance_kind = "dead ends" if dead_end else "non-dead ends" + region_access_requirement = "requires" if require_new_exits else "does not require" + raise EntranceRandomizationError( + f"None of the available entrances are valid targets for the available exits.\n" + f"Randomization stage is placing {entrance_kind} and {region_access_requirement} " + f"new region/exit access by default\n" + f"Placeable entrances: {lookup}\n" + f"Placeable exits: {placeable_exits}\n" + f"All unplaced entrances: {unplaced_entrances}\n" + f"All unplaced exits: {unplaced_exits}") + + if not er_targets: + er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player) + for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name) + if not exits: + exits = sorted([ex for region in world.multiworld.get_regions(world.player) + for ex in region.exits if not ex.connected_region], key=lambda x: x.name) + if len(er_targets) != len(exits): + raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of " + f"entrances ({len(er_targets)}) and exits ({len(exits)}.") + for entrance in er_targets: + entrance_lookup.add(entrance) + + # place the menu region and connected start region(s) + er_state.collection_state.update_reachable_regions(world.player) + + # stage 1 - try to place all the non-dead-end entrances + while entrance_lookup.others: + if not find_pairing(dead_end=False, require_new_exits=True): + break + # stage 2 - try to place all the dead-end entrances + while entrance_lookup.dead_ends: + if not find_pairing(dead_end=True, require_new_exits=True): + break + # stage 3 - all the regions should be placed at this point. We now need to connect dangling edges + # stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions) + # doing this before the non-dead-ends is important to ensure there are enough connections to + # go around + while entrance_lookup.dead_ends: + find_pairing(dead_end=True, require_new_exits=False) + # stage 3b - tie all the other loose ends connecting visited regions to each other + while entrance_lookup.others: + find_pairing(dead_end=False, require_new_exits=False) + + running_time = time.perf_counter() - start_time + if running_time > 1.0: + logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player}," + f"named {world.multiworld.player_name[world.player]}") + + return er_state diff --git a/test/general/test_entrance_rando.py b/test/general/test_entrance_rando.py new file mode 100644 index 000000000000..efbcf7df4636 --- /dev/null +++ b/test/general/test_entrance_rando.py @@ -0,0 +1,387 @@ +import unittest +from enum import IntEnum + +from BaseClasses import Region, EntranceType, MultiWorld, Entrance +from entrance_rando import disconnect_entrance_for_randomization, randomize_entrances, EntranceRandomizationError, \ + ERPlacementState, EntranceLookup, bake_target_group_lookup +from Options import Accessibility +from test.general import generate_test_multiworld, generate_locations, generate_items +from worlds.generic.Rules import set_rule + + +class ERTestGroups(IntEnum): + LEFT = 1 + RIGHT = 2 + TOP = 3 + BOTTOM = 4 + + +directionally_matched_group_lookup = { + ERTestGroups.LEFT: [ERTestGroups.RIGHT], + ERTestGroups.RIGHT: [ERTestGroups.LEFT], + ERTestGroups.TOP: [ERTestGroups.BOTTOM], + ERTestGroups.BOTTOM: [ERTestGroups.TOP] +} + + +def generate_entrance_pair(region: Region, name_suffix: str, group: int): + lx = region.create_exit(region.name + name_suffix) + lx.randomization_group = group + lx.randomization_type = EntranceType.TWO_WAY + le = region.create_er_target(region.name + name_suffix) + le.randomization_group = group + le.randomization_type = EntranceType.TWO_WAY + + +def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length: int, region_size: int = 0, + region_type: type[Region] = Region): + """ + Generates a grid-like region structure for ER testing, where menu is connected to the top-left region, and each + region "in vanilla" has 2 2-way exits going either down or to the right, until reaching the goal region in the + bottom right + """ + for row in range(grid_side_length): + for col in range(grid_side_length): + index = row * grid_side_length + col + name = f"region{index}" + region = region_type(name, 1, multiworld) + multiworld.regions.append(region) + generate_locations(region_size, 1, region=region, tag=f"_{name}") + + if row == 0 and col == 0: + multiworld.get_region("Menu", 1).connect(region) + if col != 0: + generate_entrance_pair(region, "_left", ERTestGroups.LEFT) + if col != grid_side_length - 1: + generate_entrance_pair(region, "_right", ERTestGroups.RIGHT) + if row != 0: + generate_entrance_pair(region, "_top", ERTestGroups.TOP) + if row != grid_side_length - 1: + generate_entrance_pair(region, "_bottom", ERTestGroups.BOTTOM) + + +class TestEntranceLookup(unittest.TestCase): + def test_shuffled_targets(self): + """tests that get_targets shuffles targets between groups when requested""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True) + er_targets = [entrance for region in multiworld.get_regions(1) + for entrance in region.entrances if not entrance.parent_region] + for entrance in er_targets: + lookup.add(entrance) + + retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM], + False, False) + prev = None + group_order = [prev := group.randomization_group for group in retrieved_targets + if prev != group.randomization_group] + # technically possible that group order may not be shuffled, by some small chance, on some seeds. but generally + # a shuffled list should alternate more frequently which is the desired behavior here + self.assertGreater(len(group_order), 2) + + + def test_ordered_targets(self): + """tests that get_targets does not shuffle targets between groups when requested""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True) + er_targets = [entrance for region in multiworld.get_regions(1) + for entrance in region.entrances if not entrance.parent_region] + for entrance in er_targets: + lookup.add(entrance) + + retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM], + False, True) + prev = None + group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group] + self.assertEqual([ERTestGroups.TOP, ERTestGroups.BOTTOM], group_order) + + +class TestBakeTargetGroupLookup(unittest.TestCase): + def test_lookup_generation(self): + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + world = multiworld.worlds[1] + expected = { + ERTestGroups.LEFT: [-ERTestGroups.LEFT], + ERTestGroups.RIGHT: [-ERTestGroups.RIGHT], + ERTestGroups.TOP: [-ERTestGroups.TOP], + ERTestGroups.BOTTOM: [-ERTestGroups.BOTTOM] + } + actual = bake_target_group_lookup(world, lambda g: [-g]) + self.assertEqual(expected, actual) + + +class TestDisconnectForRandomization(unittest.TestCase): + def test_disconnect_default_2way(self): + multiworld = generate_test_multiworld() + r1 = Region("r1", 1, multiworld) + r2 = Region("r2", 1, multiworld) + e = r1.create_exit("e") + e.randomization_type = EntranceType.TWO_WAY + e.randomization_group = 1 + e.connect(r2) + + disconnect_entrance_for_randomization(e) + + self.assertIsNone(e.connected_region) + self.assertEqual([], r2.entrances) + + self.assertEqual(1, len(r1.exits)) + self.assertEqual(e, r1.exits[0]) + + self.assertEqual(1, len(r1.entrances)) + self.assertIsNone(r1.entrances[0].parent_region) + self.assertEqual("e", r1.entrances[0].name) + self.assertEqual(EntranceType.TWO_WAY, r1.entrances[0].randomization_type) + self.assertEqual(1, r1.entrances[0].randomization_group) + + def test_disconnect_default_1way(self): + multiworld = generate_test_multiworld() + r1 = Region("r1", 1, multiworld) + r2 = Region("r2", 1, multiworld) + e = r1.create_exit("e") + e.randomization_type = EntranceType.ONE_WAY + e.randomization_group = 1 + e.connect(r2) + + disconnect_entrance_for_randomization(e) + + self.assertIsNone(e.connected_region) + self.assertEqual([], r1.entrances) + + self.assertEqual(1, len(r1.exits)) + self.assertEqual(e, r1.exits[0]) + + self.assertEqual(1, len(r2.entrances)) + self.assertIsNone(r2.entrances[0].parent_region) + self.assertEqual("r2", r2.entrances[0].name) + self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type) + self.assertEqual(1, r2.entrances[0].randomization_group) + + def test_disconnect_uses_alternate_group(self): + multiworld = generate_test_multiworld() + r1 = Region("r1", 1, multiworld) + r2 = Region("r2", 1, multiworld) + e = r1.create_exit("e") + e.randomization_type = EntranceType.ONE_WAY + e.randomization_group = 1 + e.connect(r2) + + disconnect_entrance_for_randomization(e, 2) + + self.assertIsNone(e.connected_region) + self.assertEqual([], r1.entrances) + + self.assertEqual(1, len(r1.exits)) + self.assertEqual(e, r1.exits[0]) + + self.assertEqual(1, len(r2.entrances)) + self.assertIsNone(r2.entrances[0].parent_region) + self.assertEqual("r2", r2.entrances[0].name) + self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type) + self.assertEqual(2, r2.entrances[0].randomization_group) + + +class TestRandomizeEntrances(unittest.TestCase): + def test_determinism(self): + """tests that the same output is produced for the same input""" + multiworld1 = generate_test_multiworld() + generate_disconnected_region_grid(multiworld1, 5) + multiworld2 = generate_test_multiworld() + generate_disconnected_region_grid(multiworld2, 5) + + result1 = randomize_entrances(multiworld1.worlds[1], False, directionally_matched_group_lookup) + result2 = randomize_entrances(multiworld2.worlds[1], False, directionally_matched_group_lookup) + self.assertEqual(result1.pairings, result2.pairings) + for e1, e2 in zip(result1.placements, result2.placements): + self.assertEqual(e1.name, e2.name) + self.assertEqual(e1.parent_region.name, e1.parent_region.name) + self.assertEqual(e1.connected_region.name, e2.connected_region.name) + + def test_all_entrances_placed(self): + """tests that all entrances and exits were placed, all regions are connected, and no dangling edges exist""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + + result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) + + self.assertEqual([], [entrance for region in multiworld.get_regions() + for entrance in region.entrances if not entrance.parent_region]) + self.assertEqual([], [exit_ for region in multiworld.get_regions() + for exit_ in region.exits if not exit_.connected_region]) + # 5x5 grid + menu + self.assertEqual(26, len(result.placed_regions)) + self.assertEqual(80, len(result.pairings)) + self.assertEqual(80, len(result.placements)) + + def test_coupling(self): + """tests that in coupled mode, all 2 way transitions have an inverse""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + seen_placement_count = 0 + + def verify_coupled(_: ERPlacementState, placed_entrances: list[Entrance]): + nonlocal seen_placement_count + seen_placement_count += len(placed_entrances) + self.assertEqual(2, len(placed_entrances)) + self.assertEqual(placed_entrances[0].parent_region, placed_entrances[1].connected_region) + self.assertEqual(placed_entrances[1].parent_region, placed_entrances[0].connected_region) + + result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup, + on_connect=verify_coupled) + # if we didn't visit every placement the verification on_connect doesn't really mean much + self.assertEqual(len(result.placements), seen_placement_count) + + def test_uncoupled(self): + """tests that in uncoupled mode, no transitions have an (intentional) inverse""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + seen_placement_count = 0 + + def verify_uncoupled(state: ERPlacementState, placed_entrances: list[Entrance]): + nonlocal seen_placement_count + seen_placement_count += len(placed_entrances) + self.assertEqual(1, len(placed_entrances)) + + result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup, + on_connect=verify_uncoupled) + # if we didn't visit every placement the verification on_connect doesn't really mean much + self.assertEqual(len(result.placements), seen_placement_count) + + def test_oneway_twoway_pairing(self): + """tests that 1 ways are only paired to 1 ways and 2 ways are only paired to 2 ways""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + region26 = Region("region26", 1, multiworld) + multiworld.regions.append(region26) + for index, region in enumerate(["region4", "region20", "region24"]): + x = multiworld.get_region(region, 1).create_exit(f"{region}_bottom_1way") + x.randomization_type = EntranceType.ONE_WAY + x.randomization_group = ERTestGroups.BOTTOM + e = region26.create_er_target(f"region26_top_1way{index}") + e.randomization_type = EntranceType.ONE_WAY + e.randomization_group = ERTestGroups.TOP + + result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) + for exit_name, entrance_name in result.pairings: + # we have labeled our entrances in such a way that all the 1 way entrances have 1way in the name, + # so test for that since the ER target will have been discarded + if "1way" in exit_name: + self.assertIn("1way", entrance_name) + + def test_group_constraints_satisfied(self): + """tests that all grouping constraints are satisfied""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + + result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) + for exit_name, entrance_name in result.pairings: + # we have labeled our entrances in such a way that all the entrances contain their group in the name + # so test for that since the ER target will have been discarded + if "top" in exit_name: + self.assertIn("bottom", entrance_name) + if "bottom" in exit_name: + self.assertIn("top", entrance_name) + if "left" in exit_name: + self.assertIn("right", entrance_name) + if "right" in exit_name: + self.assertIn("left", entrance_name) + + def test_minimal_entrance_rando(self): + """tests that entrance randomization can complete with minimal accessibility and unreachable exits""" + multiworld = generate_test_multiworld() + multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal) + multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1) + generate_disconnected_region_grid(multiworld, 5, 1) + prog_items = generate_items(10, 1, True) + multiworld.itempool += prog_items + filler_items = generate_items(15, 1, False) + multiworld.itempool += filler_items + e = multiworld.get_entrance("region1_right", 1) + set_rule(e, lambda state: False) + + randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) + + self.assertEqual([], [entrance for region in multiworld.get_regions() + for entrance in region.entrances if not entrance.parent_region]) + self.assertEqual([], [exit_ for region in multiworld.get_regions() + for exit_ in region.exits if not exit_.connected_region]) + + def test_restrictive_region_requirement_does_not_fail(self): + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 2, 1) + + region = Region("region4", 1, multiworld) + multiworld.regions.append(region) + generate_entrance_pair(multiworld.get_region("region0", 1), "_right2", ERTestGroups.RIGHT) + generate_entrance_pair(region, "_left", ERTestGroups.LEFT) + + blocked_exits = ["region1_left", "region1_bottom", + "region2_top", "region2_right", + "region3_left", "region3_top"] + for exit_name in blocked_exits: + blocked_exit = multiworld.get_entrance(exit_name, 1) + blocked_exit.access_rule = lambda state: state.can_reach_region("region4", 1) + multiworld.register_indirect_condition(region, blocked_exit) + + result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup) + # verifying that we did in fact place region3 adjacent to region0 to unblock all the other connections + # (and implicitly, that ER didn't fail) + self.assertTrue(("region0_right", "region4_left") in result.pairings + or ("region0_right2", "region4_left") in result.pairings) + + def test_fails_when_mismatched_entrance_and_exit_count(self): + """tests that entrance randomization fast-fails if the input exit and entrance count do not match""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + multiworld.get_region("region1", 1).create_exit("extra") + + self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, + directionally_matched_group_lookup) + + def test_fails_when_some_unreachable_exit(self): + """tests that entrance randomization fails if an exit is never reachable (non-minimal accessibility)""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + e = multiworld.get_entrance("region1_right", 1) + set_rule(e, lambda state: False) + + self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, + directionally_matched_group_lookup) + + def test_fails_when_some_unconnectable_exit(self): + """tests that entrance randomization fails if an exit can't be made into a valid placement (non-minimal)""" + class CustomEntrance(Entrance): + def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool: + if other.name == "region1_right": + return False + + class CustomRegion(Region): + entrance_type = CustomEntrance + + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5, region_type=CustomRegion) + + self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, + directionally_matched_group_lookup) + + def test_minimal_er_fails_when_not_enough_locations_to_fit_progression(self): + """ + tests that entrance randomization fails in minimal accessibility if there are not enough locations + available to place all progression items locally + """ + multiworld = generate_test_multiworld() + multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal) + multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1) + generate_disconnected_region_grid(multiworld, 5, 1) + prog_items = generate_items(30, 1, True) + multiworld.itempool += prog_items + e = multiworld.get_entrance("region1_right", 1) + set_rule(e, lambda state: False) + + self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, + directionally_matched_group_lookup) From 3bcc86f5391ea00d220bf6bf094a4a08801b162b Mon Sep 17 00:00:00 2001 From: Kory Dondzila Date: Fri, 27 Dec 2024 15:07:55 -0500 Subject: [PATCH 056/144] Shivers: Add events and fix require puzzle hints logic (#4018) * Adds some events, renames things, fails for many players. * Adds entrance rules for requires hints. * Cleanup and add goal item. * Cleanup. * Add additional rule. * Event and regions additions. * Updates from merge. * Adds collect behavior option. * Fix missing generator location. * Fix whitespace and optimize imports. * Switch location order back. * Add name replacement for storage. * Fix test failure. * Improve puzzle hints required. * Add missing locations and cleanup indirect conditions. * Fix naming. * PR feedback. * Missed comment. * Cleanup imports, use strings for option equivalence, and update option description. * Fix rule. * Create rolling buffer goal items and remove goal items and location from default options. * Cleanup. * Removes dateutil. * Fixes Subterranean World information plaque. --- docs/CODEOWNERS | 2 +- worlds/shivers/Constants.py | 20 +- worlds/shivers/Items.py | 306 +++++++++++-------- worlds/shivers/Options.py | 100 ++++++- worlds/shivers/Rules.py | 315 ++++++++++++-------- worlds/shivers/__init__.py | 264 +++++++++------- worlds/shivers/data/excluded_locations.json | 2 +- worlds/shivers/data/locations.json | 144 +++++---- worlds/shivers/data/regions.json | 88 +++--- worlds/shivers/docs/en_Shivers.md | 3 +- 10 files changed, 783 insertions(+), 461 deletions(-) diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index d58207806743..1d70531e9974 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -152,7 +152,7 @@ /worlds/saving_princess/ @LeonarthCG # Shivers -/worlds/shivers/ @GodlFire +/worlds/shivers/ @GodlFire @korydondzila # A Short Hike /worlds/shorthike/ @chandler05 @BrandenEK diff --git a/worlds/shivers/Constants.py b/worlds/shivers/Constants.py index 95b3c2d56ad9..9b7f3dcebc4f 100644 --- a/worlds/shivers/Constants.py +++ b/worlds/shivers/Constants.py @@ -1,17 +1,25 @@ -import os import json +import os import pkgutil +from datetime import datetime + def load_data_file(*args) -> dict: fname = "/".join(["data", *args]) return json.loads(pkgutil.get_data(__name__, fname).decode()) -location_id_offset: int = 27000 -location_info = load_data_file("locations.json") -location_name_to_id = {name: location_id_offset + index \ - for index, name in enumerate(location_info["all_locations"])} +def relative_years_from_today(dt2: datetime) -> int: + today = datetime.now() + years = today.year - dt2.year + if today.month < dt2.month or (today.month == dt2.month and today.day < dt2.day): + years -= 1 + return years -exclusion_info = load_data_file("excluded_locations.json") +location_id_offset: int = 27000 +location_info = load_data_file("locations.json") +location_name_to_id = {name: location_id_offset + index for index, name in enumerate(location_info["all_locations"])} +exclusion_info = load_data_file("excluded_locations.json") region_info = load_data_file("regions.json") +years_since_sep_30_1980 = relative_years_from_today(datetime.fromisoformat("1980-09-30")) diff --git a/worlds/shivers/Items.py b/worlds/shivers/Items.py index 10d234d450bb..a60bad17b8ed 100644 --- a/worlds/shivers/Items.py +++ b/worlds/shivers/Items.py @@ -1,132 +1,198 @@ +import enum +from typing import NamedTuple, Optional + from BaseClasses import Item, ItemClassification -import typing +from . import Constants + class ShiversItem(Item): game: str = "Shivers" -class ItemData(typing.NamedTuple): - code: int - type: str + +class ItemType(enum.Enum): + POT = "pot" + POT_COMPLETE = "pot-complete" + POT_DUPLICATE = "pot-duplicate" + POT_COMPELTE_DUPLICATE = "pot-complete-duplicate" + KEY = "key" + KEY_OPTIONAL = "key-optional" + ABILITY = "ability" + FILLER = "filler" + IXUPI_AVAILABILITY = "ixupi-availability" + GOAL = "goal" + + +class ItemData(NamedTuple): + code: Optional[int] + type: ItemType classification: ItemClassification = ItemClassification.progression + SHIVERS_ITEM_ID_OFFSET = 27000 +# To allow for an item with a name that changes over time (once a year) +# while keeping the id unique we can generate a small range of them. +goal_items = { + f"Mt. Pleasant Tribune: {Constants.years_since_sep_30_1980 + year_offset} year Old Mystery Solved!": ItemData( + SHIVERS_ITEM_ID_OFFSET + 100 + Constants.years_since_sep_30_1980 + year_offset, ItemType.GOAL + ) for year_offset in range(-1, 2) +} + item_table = { - #Pot Pieces - "Water Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 0, "pot"), - "Wax Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 1, "pot"), - "Ash Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 2, "pot"), - "Oil Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 3, "pot"), - "Cloth Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 4, "pot"), - "Wood Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 5, "pot"), - "Crystal Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 6, "pot"), - "Lightning Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 7, "pot"), - "Sand Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 8, "pot"), - "Metal Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 9, "pot"), - "Water Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 10, "pot"), - "Wax Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 11, "pot"), - "Ash Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 12, "pot"), - "Oil Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 13, "pot"), - "Cloth Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 14, "pot"), - "Wood Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 15, "pot"), - "Crystal Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 16, "pot"), - "Lightning Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 17, "pot"), - "Sand Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 18, "pot"), - "Metal Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 19, "pot"), - "Water Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 20, "pot_type2"), - "Wax Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 21, "pot_type2"), - "Ash Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 22, "pot_type2"), - "Oil Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 23, "pot_type2"), - "Cloth Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 24, "pot_type2"), - "Wood Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 25, "pot_type2"), - "Crystal Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 26, "pot_type2"), - "Lightning Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 27, "pot_type2"), - "Sand Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 28, "pot_type2"), - "Metal Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 29, "pot_type2"), - - #Keys - "Key for Office Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 30, "key"), - "Key for Bedroom Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 31, "key"), - "Key for Three Floor Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 32, "key"), - "Key for Workshop": ItemData(SHIVERS_ITEM_ID_OFFSET + 33, "key"), - "Key for Office": ItemData(SHIVERS_ITEM_ID_OFFSET + 34, "key"), - "Key for Prehistoric Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 35, "key"), - "Key for Greenhouse Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 36, "key"), - "Key for Ocean Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 37, "key"), - "Key for Projector Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 38, "key"), - "Key for Generator Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 39, "key"), - "Key for Egypt Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 40, "key"), - "Key for Library Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 41, "key"), - "Key for Shaman Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 42, "key"), - "Key for UFO Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 43, "key"), - "Key for Torture Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 44, "key"), - "Key for Puzzle Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 45, "key"), - "Key for Bedroom": ItemData(SHIVERS_ITEM_ID_OFFSET + 46, "key"), - "Key for Underground Lake Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 47, "key"), - "Key for Janitor Closet": ItemData(SHIVERS_ITEM_ID_OFFSET + 48, "key"), - "Key for Front Door": ItemData(SHIVERS_ITEM_ID_OFFSET + 49, "key-optional"), - - #Abilities - "Crawling": ItemData(SHIVERS_ITEM_ID_OFFSET + 50, "ability"), - - #Event Items - "Victory": ItemData(SHIVERS_ITEM_ID_OFFSET + 60, "victory"), - - #Duplicate pot pieces for fill_Restrictive - "Water Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 70, "potduplicate"), - "Wax Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 71, "potduplicate"), - "Ash Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 72, "potduplicate"), - "Oil Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 73, "potduplicate"), - "Cloth Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 74, "potduplicate"), - "Wood Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 75, "potduplicate"), - "Crystal Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 76, "potduplicate"), - "Lightning Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 77, "potduplicate"), - "Sand Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 78, "potduplicate"), - "Metal Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 79, "potduplicate"), - "Water Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 80, "potduplicate"), - "Wax Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 81, "potduplicate"), - "Ash Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 82, "potduplicate"), - "Oil Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 83, "potduplicate"), - "Cloth Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 84, "potduplicate"), - "Wood Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 85, "potduplicate"), - "Crystal Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 86, "potduplicate"), - "Lightning Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 87, "potduplicate"), - "Sand Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 88, "potduplicate"), - "Metal Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 89, "potduplicate"), - "Water Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 140, "potduplicate_type2"), - "Wax Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 141, "potduplicate_type2"), - "Ash Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 142, "potduplicate_type2"), - "Oil Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 143, "potduplicate_type2"), - "Cloth Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 144, "potduplicate_type2"), - "Wood Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 145, "potduplicate_type2"), - "Crystal Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 146, "potduplicate_type2"), - "Lightning Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 147, "potduplicate_type2"), - "Sand Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 148, "potduplicate_type2"), - "Metal Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 149, "potduplicate_type2"), - - #Filler - "Empty": ItemData(SHIVERS_ITEM_ID_OFFSET + 90, "filler"), - "Easier Lyre": ItemData(SHIVERS_ITEM_ID_OFFSET + 91, "filler", ItemClassification.filler), - "Water Always Available in Lobby": ItemData(SHIVERS_ITEM_ID_OFFSET + 92, "filler2", ItemClassification.filler), - "Wax Always Available in Library": ItemData(SHIVERS_ITEM_ID_OFFSET + 93, "filler2", ItemClassification.filler), - "Wax Always Available in Anansi Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 94, "filler2", ItemClassification.filler), - "Wax Always Available in Shaman Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 95, "filler2", ItemClassification.filler), - "Ash Always Available in Office": ItemData(SHIVERS_ITEM_ID_OFFSET + 96, "filler2", ItemClassification.filler), - "Ash Always Available in Burial Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 97, "filler2", ItemClassification.filler), - "Oil Always Available in Prehistoric Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 98, "filler2", ItemClassification.filler), - "Cloth Always Available in Egypt": ItemData(SHIVERS_ITEM_ID_OFFSET + 99, "filler2", ItemClassification.filler), - "Cloth Always Available in Burial Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 100, "filler2", ItemClassification.filler), - "Wood Always Available in Workshop": ItemData(SHIVERS_ITEM_ID_OFFSET + 101, "filler2", ItemClassification.filler), - "Wood Always Available in Blue Maze": ItemData(SHIVERS_ITEM_ID_OFFSET + 102, "filler2", ItemClassification.filler), - "Wood Always Available in Pegasus Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 103, "filler2", ItemClassification.filler), - "Wood Always Available in Gods Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 104, "filler2", ItemClassification.filler), - "Crystal Always Available in Lobby": ItemData(SHIVERS_ITEM_ID_OFFSET + 105, "filler2", ItemClassification.filler), - "Crystal Always Available in Ocean": ItemData(SHIVERS_ITEM_ID_OFFSET + 106, "filler2", ItemClassification.filler), - "Sand Always Available in Greenhouse Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 107, "filler2", ItemClassification.filler), - "Sand Always Available in Ocean": ItemData(SHIVERS_ITEM_ID_OFFSET + 108, "filler2", ItemClassification.filler), - "Metal Always Available in Projector Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 109, "filler2", ItemClassification.filler), - "Metal Always Available in Bedroom": ItemData(SHIVERS_ITEM_ID_OFFSET + 110, "filler2", ItemClassification.filler), - "Metal Always Available in Prehistoric": ItemData(SHIVERS_ITEM_ID_OFFSET + 111, "filler2", ItemClassification.filler), - "Heal": ItemData(SHIVERS_ITEM_ID_OFFSET + 112, "filler3", ItemClassification.filler) + # Pot Pieces + "Water Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 0, ItemType.POT), + "Wax Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 1, ItemType.POT), + "Ash Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 2, ItemType.POT), + "Oil Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 3, ItemType.POT), + "Cloth Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 4, ItemType.POT), + "Wood Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 5, ItemType.POT), + "Crystal Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 6, ItemType.POT), + "Lightning Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 7, ItemType.POT), + "Sand Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 8, ItemType.POT), + "Metal Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 9, ItemType.POT), + "Water Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 10, ItemType.POT), + "Wax Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 11, ItemType.POT), + "Ash Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 12, ItemType.POT), + "Oil Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 13, ItemType.POT), + "Cloth Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 14, ItemType.POT), + "Wood Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 15, ItemType.POT), + "Crystal Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 16, ItemType.POT), + "Lightning Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 17, ItemType.POT), + "Sand Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 18, ItemType.POT), + "Metal Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 19, ItemType.POT), + "Water Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 20, ItemType.POT_COMPLETE), + "Wax Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 21, ItemType.POT_COMPLETE), + "Ash Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 22, ItemType.POT_COMPLETE), + "Oil Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 23, ItemType.POT_COMPLETE), + "Cloth Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 24, ItemType.POT_COMPLETE), + "Wood Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 25, ItemType.POT_COMPLETE), + "Crystal Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 26, ItemType.POT_COMPLETE), + "Lightning Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 27, ItemType.POT_COMPLETE), + "Sand Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 28, ItemType.POT_COMPLETE), + "Metal Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 29, ItemType.POT_COMPLETE), + + # Keys + "Key for Office Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 30, ItemType.KEY), + "Key for Bedroom Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 31, ItemType.KEY), + "Key for Three Floor Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 32, ItemType.KEY), + "Key for Workshop": ItemData(SHIVERS_ITEM_ID_OFFSET + 33, ItemType.KEY), + "Key for Office": ItemData(SHIVERS_ITEM_ID_OFFSET + 34, ItemType.KEY), + "Key for Prehistoric Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 35, ItemType.KEY), + "Key for Greenhouse": ItemData(SHIVERS_ITEM_ID_OFFSET + 36, ItemType.KEY), + "Key for Ocean Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 37, ItemType.KEY), + "Key for Projector Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 38, ItemType.KEY), + "Key for Generator Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 39, ItemType.KEY), + "Key for Egypt Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 40, ItemType.KEY), + "Key for Library": ItemData(SHIVERS_ITEM_ID_OFFSET + 41, ItemType.KEY), + "Key for Shaman Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 42, ItemType.KEY), + "Key for UFO Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 43, ItemType.KEY), + "Key for Torture Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 44, ItemType.KEY), + "Key for Puzzle Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 45, ItemType.KEY), + "Key for Bedroom": ItemData(SHIVERS_ITEM_ID_OFFSET + 46, ItemType.KEY), + "Key for Underground Lake": ItemData(SHIVERS_ITEM_ID_OFFSET + 47, ItemType.KEY), + "Key for Janitor Closet": ItemData(SHIVERS_ITEM_ID_OFFSET + 48, ItemType.KEY), + "Key for Front Door": ItemData(SHIVERS_ITEM_ID_OFFSET + 49, ItemType.KEY_OPTIONAL), + + # Abilities + "Crawling": ItemData(SHIVERS_ITEM_ID_OFFSET + 50, ItemType.ABILITY), + + # Duplicate pot pieces for fill_Restrictive + "Water Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Wax Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Ash Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Oil Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Cloth Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Wood Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Crystal Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Lightning Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Sand Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Metal Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Water Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Wax Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Ash Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Oil Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Cloth Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Wood Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Crystal Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Lightning Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Sand Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Metal Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Water Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Wax Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Ash Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Oil Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Cloth Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Wood Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Crystal Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Lightning Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Sand Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Metal Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + + # Filler + "Empty": ItemData(None, ItemType.FILLER, ItemClassification.filler), + "Easier Lyre": ItemData(SHIVERS_ITEM_ID_OFFSET + 91, ItemType.FILLER, ItemClassification.useful), + "Water Always Available in Lobby": ItemData( + SHIVERS_ITEM_ID_OFFSET + 92, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wax Always Available in Library": ItemData( + SHIVERS_ITEM_ID_OFFSET + 93, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wax Always Available in Anansi Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 94, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wax Always Available in Shaman Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 95, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Ash Always Available in Office": ItemData( + SHIVERS_ITEM_ID_OFFSET + 96, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Ash Always Available in Burial Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 97, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Oil Always Available in Prehistoric Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 98, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Cloth Always Available in Egypt": ItemData( + SHIVERS_ITEM_ID_OFFSET + 99, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Cloth Always Available in Burial Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 100, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wood Always Available in Workshop": ItemData( + SHIVERS_ITEM_ID_OFFSET + 101, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wood Always Available in Blue Maze": ItemData( + SHIVERS_ITEM_ID_OFFSET + 102, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wood Always Available in Pegasus Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 103, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wood Always Available in Gods Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 104, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Crystal Always Available in Lobby": ItemData( + SHIVERS_ITEM_ID_OFFSET + 105, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Crystal Always Available in Ocean": ItemData( + SHIVERS_ITEM_ID_OFFSET + 106, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Sand Always Available in Greenhouse": ItemData( + SHIVERS_ITEM_ID_OFFSET + 107, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Sand Always Available in Ocean": ItemData( + SHIVERS_ITEM_ID_OFFSET + 108, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Metal Always Available in Projector Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 109, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Metal Always Available in Bedroom": ItemData( + SHIVERS_ITEM_ID_OFFSET + 110, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Metal Always Available in Prehistoric": ItemData( + SHIVERS_ITEM_ID_OFFSET + 111, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Heal": ItemData(SHIVERS_ITEM_ID_OFFSET + 112, ItemType.FILLER, ItemClassification.filler), + # Goal items + **goal_items } diff --git a/worlds/shivers/Options.py b/worlds/shivers/Options.py index 72791bef3e7b..2e68c4beecc0 100644 --- a/worlds/shivers/Options.py +++ b/worlds/shivers/Options.py @@ -1,6 +1,11 @@ -from Options import Choice, DefaultOnToggle, Toggle, PerGameCommonOptions, Range from dataclasses import dataclass +from Options import ( + Choice, DefaultOnToggle, ItemDict, ItemSet, LocationSet, OptionGroup, PerGameCommonOptions, Range, Toggle, +) +from . import ItemType, item_table +from .Constants import location_info + class IxupiCapturesNeeded(Range): """ @@ -11,12 +16,13 @@ class IxupiCapturesNeeded(Range): range_end = 10 default = 10 + class LobbyAccess(Choice): """ Chooses how keys needed to reach the lobby are placed. - Normal: Keys are placed anywhere - Early: Keys are placed early - - Local: Keys are placed locally + - Local: Keys are placed locally and early """ display_name = "Lobby Access" option_normal = 0 @@ -24,16 +30,19 @@ class LobbyAccess(Choice): option_local = 2 default = 1 + class PuzzleHintsRequired(DefaultOnToggle): """ If turned on puzzle hints/solutions will be available before the corresponding puzzle is required. - For example: The Red Door puzzle will be logically required only after access to the Beth's Address Book which gives you the solution. + For example: The Red Door puzzle will be logically required only after obtaining access to Beth's Address Book + which gives you the solution. Turning this off allows for greater randomization. """ display_name = "Puzzle Hints Required" + class InformationPlaques(Toggle): """ Adds Information Plaques as checks. @@ -41,12 +50,14 @@ class InformationPlaques(Toggle): """ display_name = "Include Information Plaques" + class FrontDoorUsable(Toggle): """ Adds a key to unlock the front door of the museum. """ display_name = "Front Door Usable" + class ElevatorsStaySolved(DefaultOnToggle): """ Adds elevators as checks and will remain open upon solving them. @@ -54,12 +65,15 @@ class ElevatorsStaySolved(DefaultOnToggle): """ display_name = "Elevators Stay Solved" + class EarlyBeth(DefaultOnToggle): """ - Beth's body is open at the start of the game. This allows any pot piece to be placed in the slide and early checks on the second half of the final riddle. + Beth's body is open at the start of the game. + This allows any pot piece to be placed in the slide and early checks on the second half of the final riddle. """ display_name = "Early Beth" + class EarlyLightning(Toggle): """ Allows lightning to be captured at any point in the game. You will still need to capture all ten Ixupi for victory. @@ -67,6 +81,7 @@ class EarlyLightning(Toggle): """ display_name = "Early Lightning" + class LocationPotPieces(Choice): """ Chooses where pot pieces will be located within the multiworld. @@ -78,6 +93,8 @@ class LocationPotPieces(Choice): option_own_world = 0 option_different_world = 1 option_any_world = 2 + default = 2 + class FullPots(Choice): """ @@ -107,6 +124,61 @@ class PuzzleCollectBehavior(Choice): default = 1 +# Need to override the default options to remove the goal items and goal locations so that they do not show on web. +valid_item_keys = [name for name, data in item_table.items() if data.type != ItemType.GOAL and data.code is not None] +valid_location_keys = [name for name in location_info["all_locations"] if name != "Mystery Solved"] + + +class LocalItems(ItemSet): + """Forces these items to be in their native world.""" + display_name = "Local Items" + rich_text_doc = True + valid_keys = valid_item_keys + + +class NonLocalItems(ItemSet): + """Forces these items to be outside their native world.""" + display_name = "Non-local Items" + rich_text_doc = True + valid_keys = valid_item_keys + + +class StartInventory(ItemDict): + """Start with these items.""" + verify_item_name = True + display_name = "Start Inventory" + rich_text_doc = True + valid_keys = valid_item_keys + + +class StartHints(ItemSet): + """Start with these item's locations prefilled into the ``!hint`` command.""" + display_name = "Start Hints" + rich_text_doc = True + valid_keys = valid_item_keys + + +class StartLocationHints(LocationSet): + """Start with these locations and their item prefilled into the ``!hint`` command.""" + display_name = "Start Location Hints" + rich_text_doc = True + valid_keys = valid_location_keys + + +class ExcludeLocations(LocationSet): + """Prevent these locations from having an important item.""" + display_name = "Excluded Locations" + rich_text_doc = True + valid_keys = valid_location_keys + + +class PriorityLocations(LocationSet): + """Prevent these locations from having an unimportant item.""" + display_name = "Priority Locations" + rich_text_doc = True + valid_keys = valid_location_keys + + @dataclass class ShiversOptions(PerGameCommonOptions): ixupi_captures_needed: IxupiCapturesNeeded @@ -120,3 +192,23 @@ class ShiversOptions(PerGameCommonOptions): location_pot_pieces: LocationPotPieces full_pots: FullPots puzzle_collect_behavior: PuzzleCollectBehavior + local_items: LocalItems + non_local_items: NonLocalItems + start_inventory: StartInventory + start_hints: StartHints + start_location_hints: StartLocationHints + exclude_locations: ExcludeLocations + priority_locations: PriorityLocations + + +shivers_option_groups = [ + OptionGroup("Item & Location Options", [ + LocalItems, + NonLocalItems, + StartInventory, + StartHints, + StartLocationHints, + ExcludeLocations, + PriorityLocations + ], True), +] diff --git a/worlds/shivers/Rules.py b/worlds/shivers/Rules.py index 5288fa2c9c3f..d6ea0fca5926 100644 --- a/worlds/shivers/Rules.py +++ b/worlds/shivers/Rules.py @@ -1,66 +1,69 @@ -from typing import Dict, TYPE_CHECKING from collections.abc import Callable +from typing import Dict, TYPE_CHECKING + from BaseClasses import CollectionState from worlds.generic.Rules import forbid_item +from . import Constants if TYPE_CHECKING: from . import ShiversWorld def water_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Water Pot Bottom", "Water Pot Top", "Water Pot Bottom DUPE", "Water Pot Top DUPE"}, player) or \ - state.has_all({"Water Pot Complete", "Water Pot Complete DUPE"}, player) + return state.has_all({"Water Pot Bottom", "Water Pot Top", "Water Pot Bottom DUPE", "Water Pot Top DUPE"}, player) \ + or state.has_all({"Water Pot Complete", "Water Pot Complete DUPE"}, player) def wax_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Wax Pot Bottom", "Wax Pot Top", "Wax Pot Bottom DUPE", "Wax Pot Top DUPE"}, player) or \ - state.has_all({"Wax Pot Complete", "Wax Pot Complete DUPE"}, player) + return state.has_all({"Wax Pot Bottom", "Wax Pot Top", "Wax Pot Bottom DUPE", "Wax Pot Top DUPE"}, player) \ + or state.has_all({"Wax Pot Complete", "Wax Pot Complete DUPE"}, player) def ash_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Ash Pot Bottom", "Ash Pot Top", "Ash Pot Bottom DUPE", "Ash Pot Top DUPE"}, player) or \ - state.has_all({"Ash Pot Complete", "Ash Pot Complete DUPE"}, player) + return state.has_all({"Ash Pot Bottom", "Ash Pot Top", "Ash Pot Bottom DUPE", "Ash Pot Top DUPE"}, player) \ + or state.has_all({"Ash Pot Complete", "Ash Pot Complete DUPE"}, player) def oil_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Oil Pot Bottom", "Oil Pot Top", "Oil Pot Bottom DUPE", "Oil Pot Top DUPE"}, player) or \ - state.has_all({"Oil Pot Complete", "Oil Pot Complete DUPE"}, player) + return state.has_all({"Oil Pot Bottom", "Oil Pot Top", "Oil Pot Bottom DUPE", "Oil Pot Top DUPE"}, player) \ + or state.has_all({"Oil Pot Complete", "Oil Pot Complete DUPE"}, player) def cloth_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Cloth Pot Bottom", "Cloth Pot Top", "Cloth Pot Bottom DUPE", "Cloth Pot Top DUPE"}, player) or \ - state.has_all({"Cloth Pot Complete", "Cloth Pot Complete DUPE"}, player) + return state.has_all({"Cloth Pot Bottom", "Cloth Pot Top", "Cloth Pot Bottom DUPE", "Cloth Pot Top DUPE"}, player) \ + or state.has_all({"Cloth Pot Complete", "Cloth Pot Complete DUPE"}, player) def wood_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Wood Pot Bottom", "Wood Pot Top", "Wood Pot Bottom DUPE", "Wood Pot Top DUPE"}, player) or \ - state.has_all({"Wood Pot Complete", "Wood Pot Complete DUPE"}, player) + return state.has_all({"Wood Pot Bottom", "Wood Pot Top", "Wood Pot Bottom DUPE", "Wood Pot Top DUPE"}, player) \ + or state.has_all({"Wood Pot Complete", "Wood Pot Complete DUPE"}, player) def crystal_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Crystal Pot Bottom", "Crystal Pot Top", "Crystal Pot Bottom DUPE", "Crystal Pot Top DUPE"}, player) or \ - state.has_all({"Crystal Pot Complete", "Crystal Pot Complete DUPE"}, player) + return state.has_all( + {"Crystal Pot Bottom", "Crystal Pot Top", "Crystal Pot Bottom DUPE", "Crystal Pot Top DUPE"}, player) \ + or state.has_all({"Crystal Pot Complete", "Crystal Pot Complete DUPE"}, player) def sand_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Sand Pot Bottom", "Sand Pot Top", "Sand Pot Bottom DUPE", "Sand Pot Top DUPE"}, player) or \ - state.has_all({"Sand Pot Complete", "Sand Pot Complete DUPE"}, player) + return state.has_all({"Sand Pot Bottom", "Sand Pot Top", "Sand Pot Bottom DUPE", "Sand Pot Top DUPE"}, player) \ + or state.has_all({"Sand Pot Complete", "Sand Pot Complete DUPE"}, player) def metal_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Metal Pot Bottom", "Metal Pot Top", "Metal Pot Bottom DUPE", "Metal Pot Top DUPE"}, player) or \ - state.has_all({"Metal Pot Complete", "Metal Pot Complete DUPE"}, player) + return state.has_all({"Metal Pot Bottom", "Metal Pot Top", "Metal Pot Bottom DUPE", "Metal Pot Top DUPE"}, player) \ + or state.has_all({"Metal Pot Complete", "Metal Pot Complete DUPE"}, player) -def lightning_capturable(state: CollectionState, player: int) -> bool: - return (first_nine_ixupi_capturable(state, player) or state.multiworld.worlds[player].options.early_lightning.value) \ - and (state.has_all({"Lightning Pot Bottom", "Lightning Pot Top", "Lightning Pot Bottom DUPE", "Lightning Pot Top DUPE"}, player) or \ - state.has_all({"Lightning Pot Complete", "Lightning Pot Complete DUPE"}, player)) +def lightning_capturable(state: CollectionState, world: "ShiversWorld", player: int) -> bool: + return (first_nine_ixupi_capturable(state, player) or world.options.early_lightning) \ + and (state.has_all( + {"Lightning Pot Bottom", "Lightning Pot Top", "Lightning Pot Bottom DUPE", "Lightning Pot Top DUPE"}, + player) or state.has_all({"Lightning Pot Complete", "Lightning Pot Complete DUPE"}, player)) -def beths_body_available(state: CollectionState, player: int) -> bool: - return (first_nine_ixupi_capturable(state, player) or state.multiworld.worlds[player].options.early_beth.value) \ - and state.can_reach("Generator", "Region", player) +def beths_body_available(state: CollectionState, world: "ShiversWorld", player: int) -> bool: + return first_nine_ixupi_capturable(state, player) or world.options.early_beth def first_nine_ixupi_capturable(state: CollectionState, player: int) -> bool: @@ -71,13 +74,22 @@ def first_nine_ixupi_capturable(state: CollectionState, player: int) -> bool: and metal_capturable(state, player) -def all_skull_dials_available(state: CollectionState, player: int) -> bool: - return state.can_reach("Prehistoric", "Region", player) and state.can_reach("Tar River", "Region", player) \ - and state.can_reach("Egypt", "Region", player) and state.can_reach("Burial", "Region", player) \ - and state.can_reach("Gods Room", "Region", player) and state.can_reach("Werewolf", "Region", player) +def all_skull_dials_set(state: CollectionState, player: int) -> bool: + return state.has_all([ + "Set Skull Dial: Prehistoric", + "Set Skull Dial: Tar River", + "Set Skull Dial: Egypt", + "Set Skull Dial: Burial", + "Set Skull Dial: Gods Room", + "Set Skull Dial: Werewolf" + ], player) + + +def completion_condition(state: CollectionState, player: int) -> bool: + return state.has(f"Mt. Pleasant Tribune: {Constants.years_since_sep_30_1980} year Old Mystery Solved!", player) -def get_rules_lookup(player: int): +def get_rules_lookup(world: "ShiversWorld", player: int): rules_lookup: Dict[str, Dict[str, Callable[[CollectionState], bool]]] = { "entrances": { "To Office Elevator From Underground Blue Tunnels": lambda state: state.has("Key for Office Elevator", player), @@ -90,48 +102,58 @@ def get_rules_lookup(player: int): "To Workshop": lambda state: state.has("Key for Workshop", player), "To Lobby From Office": lambda state: state.has("Key for Office", player), "To Office From Lobby": lambda state: state.has("Key for Office", player), - "To Library From Lobby": lambda state: state.has("Key for Library Room", player), - "To Lobby From Library": lambda state: state.has("Key for Library Room", player), + "To Library From Lobby": lambda state: state.has("Key for Library", player), + "To Lobby From Library": lambda state: state.has("Key for Library", player), "To Prehistoric From Lobby": lambda state: state.has("Key for Prehistoric Room", player), "To Lobby From Prehistoric": lambda state: state.has("Key for Prehistoric Room", player), - "To Greenhouse": lambda state: state.has("Key for Greenhouse Room", player), + "To Greenhouse": lambda state: state.has("Key for Greenhouse", player), "To Ocean From Prehistoric": lambda state: state.has("Key for Ocean Room", player), "To Prehistoric From Ocean": lambda state: state.has("Key for Ocean Room", player), "To Projector Room": lambda state: state.has("Key for Projector Room", player), - "To Generator": lambda state: state.has("Key for Generator Room", player), + "To Generator From Maintenance Tunnels": lambda state: state.has("Key for Generator Room", player), "To Lobby From Egypt": lambda state: state.has("Key for Egypt Room", player), "To Egypt From Lobby": lambda state: state.has("Key for Egypt Room", player), "To Janitor Closet": lambda state: state.has("Key for Janitor Closet", player), "To Shaman From Burial": lambda state: state.has("Key for Shaman Room", player), "To Burial From Shaman": lambda state: state.has("Key for Shaman Room", player), + "To Norse Stone From Gods Room": lambda state: state.has("Aligned Planets", player), "To Inventions From UFO": lambda state: state.has("Key for UFO Room", player), "To UFO From Inventions": lambda state: state.has("Key for UFO Room", player), + "To Orrery From UFO": lambda state: state.has("Viewed Fortune", player), "To Torture From Inventions": lambda state: state.has("Key for Torture Room", player), "To Inventions From Torture": lambda state: state.has("Key for Torture Room", player), "To Torture": lambda state: state.has("Key for Puzzle Room", player), "To Puzzle Room Mastermind From Torture": lambda state: state.has("Key for Puzzle Room", player), "To Bedroom": lambda state: state.has("Key for Bedroom", player), - "To Underground Lake From Underground Tunnels": lambda state: state.has("Key for Underground Lake Room", player), - "To Underground Tunnels From Underground Lake": lambda state: state.has("Key for Underground Lake Room", player), + "To Underground Lake From Underground Tunnels": lambda state: state.has("Key for Underground Lake", player), + "To Underground Tunnels From Underground Lake": lambda state: state.has("Key for Underground Lake", player), "To Outside From Lobby": lambda state: state.has("Key for Front Door", player), "To Lobby From Outside": lambda state: state.has("Key for Front Door", player), - "To Maintenance Tunnels From Theater Back Hallways": lambda state: state.has("Crawling", player), + "To Maintenance Tunnels From Theater Back Hallway": lambda state: state.has("Crawling", player), "To Blue Maze From Egypt": lambda state: state.has("Crawling", player), "To Egypt From Blue Maze": lambda state: state.has("Crawling", player), - "To Lobby From Tar River": lambda state: (state.has("Crawling", player) and oil_capturable(state, player)), - "To Tar River From Lobby": lambda state: (state.has("Crawling", player) and oil_capturable(state, player) and state.can_reach("Tar River", "Region", player)), - "To Burial From Egypt": lambda state: state.can_reach("Egypt", "Region", player), - "To Gods Room From Anansi": lambda state: state.can_reach("Gods Room", "Region", player), - "To Slide Room": lambda state: all_skull_dials_available(state, player), - "To Lobby From Slide Room": lambda state: beths_body_available(state, player), - "To Water Capture From Janitor Closet": lambda state: cloth_capturable(state, player) + "To Lobby From Tar River": lambda state: state.has("Crawling", player) and oil_capturable(state, player), + "To Tar River From Lobby": lambda state: state.has("Crawling", player) and oil_capturable(state, player) and state.can_reach_region("Tar River", player), + "To Burial From Egypt": lambda state: state.can_reach_region("Egypt", player), + "To Gods Room From Anansi": lambda state: state.can_reach_region("Gods Room", player), + "To Slide Room": lambda state: all_skull_dials_set(state, player), + "To Lobby From Slide Room": lambda state: state.has("Lost Your Head", player), + "To Water Capture From Janitor Closet": lambda state: cloth_capturable(state, player), + "To Victory": lambda state: ( + (water_capturable(state, player) + wax_capturable(state, player) + ash_capturable(state, player) + + oil_capturable(state, player) + cloth_capturable(state, player) + wood_capturable(state, player) + + crystal_capturable(state, player) + sand_capturable(state, player) + metal_capturable(state, player) + + lightning_capturable(state, world, player)) >= world.options.ixupi_captures_needed.value + ) }, "locations_required": { - "Puzzle Solved Anansi Musicbox": lambda state: state.can_reach("Clock Tower", "Region", player), - "Accessible: Storage: Janitor Closet": lambda state: cloth_capturable(state, player), - "Accessible: Storage: Tar River": lambda state: oil_capturable(state, player), - "Accessible: Storage: Theater": lambda state: state.can_reach("Projector Room", "Region", player), - "Accessible: Storage: Slide": lambda state: beths_body_available(state, player) and state.can_reach("Slide Room", "Region", player), + "Puzzle Solved Anansi Music Box": lambda state: state.has("Set Song", player), + "Storage: Anansi Music Box": lambda state: state.has("Set Song", player), + "Storage: Clock Tower": lambda state: state.has("Set Time", player), + "Storage: Janitor Closet": lambda state: cloth_capturable(state, player), + "Storage: Tar River": lambda state: oil_capturable(state, player), + "Storage: Theater": lambda state: state.has("Viewed Theater Movie", player), + "Storage: Slide": lambda state: state.has("Lost Your Head", player) and state.can_reach_region("Slide Room", player), "Ixupi Captured Water": lambda state: water_capturable(state, player), "Ixupi Captured Wax": lambda state: wax_capturable(state, player), "Ixupi Captured Ash": lambda state: ash_capturable(state, player), @@ -141,32 +163,28 @@ def get_rules_lookup(player: int): "Ixupi Captured Crystal": lambda state: crystal_capturable(state, player), "Ixupi Captured Sand": lambda state: sand_capturable(state, player), "Ixupi Captured Metal": lambda state: metal_capturable(state, player), - "Final Riddle: Planets Aligned": lambda state: state.can_reach("Fortune Teller", "Region", player), - "Final Riddle: Norse God Stone Message": lambda state: (state.can_reach("Fortune Teller", "Region", player) and state.can_reach("UFO", "Region", player)), - "Final Riddle: Beth's Body Page 17": lambda state: beths_body_available(state, player), - "Final Riddle: Guillotine Dropped": lambda state: beths_body_available(state, player), - "Puzzle Solved Skull Dial Door": lambda state: all_skull_dials_available(state, player), - }, - "locations_puzzle_hints": { - "Puzzle Solved Clock Tower Door": lambda state: state.can_reach("Three Floor Elevator", "Region", player), - "Puzzle Solved Clock Chains": lambda state: state.can_reach("Bedroom", "Region", player), - "Puzzle Solved Shaman Drums": lambda state: state.can_reach("Clock Tower", "Region", player), - "Puzzle Solved Red Door": lambda state: state.can_reach("Maintenance Tunnels", "Region", player), - "Puzzle Solved UFO Symbols": lambda state: state.can_reach("Library", "Region", player), - "Puzzle Solved Maze Door": lambda state: state.can_reach("Projector Room", "Region", player), - "Puzzle Solved Theater Door": lambda state: state.can_reach("Underground Lake", "Region", player), - "Puzzle Solved Columns of RA": lambda state: state.can_reach("Underground Lake", "Region", player), - "Final Riddle: Guillotine Dropped": lambda state: (beths_body_available(state, player) and state.can_reach("Underground Lake", "Region", player)) - }, + "Puzzle Solved Skull Dial Door": lambda state: all_skull_dials_set(state, player), + }, + "puzzle_hints_required": { + "Puzzle Solved Clock Tower Door": lambda state: state.can_reach_region("Three Floor Elevator", player), + "Puzzle Solved Shaman Drums": lambda state: state.can_reach_region("Clock Tower", player), + "Puzzle Solved Red Door": lambda state: state.can_reach_region("Maintenance Tunnels", player), + "Puzzle Solved UFO Symbols": lambda state: state.can_reach_region("Library", player), + "Storage: UFO": lambda state: state.can_reach_region("Library", player), + "Puzzle Solved Maze Door": lambda state: state.has("Viewed Theater Movie", player), + "Puzzle Solved Theater Door": lambda state: state.has("Viewed Egyptian Hieroglyphics Explained", player), + "Puzzle Solved Columns of RA": lambda state: state.has("Viewed Egyptian Hieroglyphics Explained", player), + "Puzzle Solved Atlantis": lambda state: state.can_reach_region("Office", player), + }, "elevators": { - "Puzzle Solved Office Elevator": lambda state: ((state.can_reach("Underground Lake", "Region", player) or state.can_reach("Office", "Region", player)) - and state.has("Key for Office Elevator", player)), - "Puzzle Solved Bedroom Elevator": lambda state: (state.can_reach("Office", "Region", player) and state.has_all({"Key for Bedroom Elevator","Crawling"}, player)), - "Puzzle Solved Three Floor Elevator": lambda state: ((state.can_reach("Maintenance Tunnels", "Region", player) or state.can_reach("Blue Maze", "Region", player)) - and state.has("Key for Three Floor Elevator", player)) - }, + "Puzzle Solved Office Elevator": lambda state: (state.can_reach_region("Underground Lake", player) or state.can_reach_region("Office", player)) + and state.has("Key for Office Elevator", player), + "Puzzle Solved Bedroom Elevator": lambda state: state.has_all({"Key for Bedroom Elevator", "Crawling"}, player), + "Puzzle Solved Three Floor Elevator": lambda state: (state.can_reach_region("Maintenance Tunnels", player) or state.can_reach_region("Blue Maze", player)) + and state.has("Key for Three Floor Elevator", player) + }, "lightning": { - "Ixupi Captured Lightning": lambda state: lightning_capturable(state, player) + "Ixupi Captured Lightning": lambda state: lightning_capturable(state, world, player) } } return rules_lookup @@ -176,69 +194,128 @@ def set_rules(world: "ShiversWorld") -> None: multiworld = world.multiworld player = world.player - rules_lookup = get_rules_lookup(player) + rules_lookup = get_rules_lookup(world, player) # Set required entrance rules for entrance_name, rule in rules_lookup["entrances"].items(): - multiworld.get_entrance(entrance_name, player).access_rule = rule + world.get_entrance(entrance_name).access_rule = rule + + world.get_region("Clock Tower Staircase").connect( + world.get_region("Clock Chains"), + "To Clock Chains From Clock Tower Staircase", + lambda state: state.can_reach_region("Bedroom", player) if world.options.puzzle_hints_required.value else True + ) + + world.get_region("Generator").connect( + world.get_region("Beth's Body"), + "To Beth's Body From Generator", + lambda state: beths_body_available(state, world, player) and ( + (state.has("Viewed Norse Stone", player) and state.can_reach_region("Theater", player)) + if world.options.puzzle_hints_required.value else True + ) + ) + + world.get_region("Torture").connect( + world.get_region("Guillotine"), + "To Guillotine From Torture", + lambda state: state.has("Viewed Page 17", player) and ( + state.has("Viewed Egyptian Hieroglyphics Explained", player) + if world.options.puzzle_hints_required.value else True + ) + ) # Set required location rules for location_name, rule in rules_lookup["locations_required"].items(): - multiworld.get_location(location_name, player).access_rule = rule + world.get_location(location_name).access_rule = rule + + world.get_location("Jukebox").access_rule = lambda state: ( + state.can_reach_region("Clock Tower", player) and ( + state.can_reach_region("Anansi", player) + if world.options.puzzle_hints_required.value else True + ) + ) # Set option location rules if world.options.puzzle_hints_required.value: - for location_name, rule in rules_lookup["locations_puzzle_hints"].items(): - multiworld.get_location(location_name, player).access_rule = rule + for location_name, rule in rules_lookup["puzzle_hints_required"].items(): + world.get_location(location_name).access_rule = rule + + world.get_entrance("To Theater From Lobby").access_rule = lambda state: state.has( + "Viewed Egyptian Hieroglyphics Explained", player + ) + + world.get_entrance("To Clock Tower Staircase From Theater Back Hallway").access_rule = lambda state: state.can_reach_region("Three Floor Elevator", player) + multiworld.register_indirect_condition( + world.get_region("Three Floor Elevator"), + world.get_entrance("To Clock Tower Staircase From Theater Back Hallway") + ) + + world.get_entrance("To Gods Room From Shaman").access_rule = lambda state: state.can_reach_region( + "Clock Tower", player + ) + multiworld.register_indirect_condition( + world.get_region("Clock Tower"), world.get_entrance("To Gods Room From Shaman") + ) + + world.get_entrance("To Anansi From Gods Room").access_rule = lambda state: state.can_reach_region( + "Maintenance Tunnels", player + ) + multiworld.register_indirect_condition( + world.get_region("Maintenance Tunnels"), world.get_entrance("To Anansi From Gods Room") + ) + + world.get_entrance("To Maze From Maze Staircase").access_rule = lambda \ + state: state.can_reach_region("Projector Room", player) + multiworld.register_indirect_condition( + world.get_region("Projector Room"), world.get_entrance("To Maze From Maze Staircase") + ) + + multiworld.register_indirect_condition( + world.get_region("Bedroom"), world.get_entrance("To Clock Chains From Clock Tower Staircase") + ) + multiworld.register_indirect_condition( + world.get_region("Theater"), world.get_entrance("To Beth's Body From Generator") + ) + if world.options.elevators_stay_solved.value: for location_name, rule in rules_lookup["elevators"].items(): - multiworld.get_location(location_name, player).access_rule = rule + world.get_location(location_name).access_rule = rule if world.options.early_lightning.value: for location_name, rule in rules_lookup["lightning"].items(): - multiworld.get_location(location_name, player).access_rule = rule + world.get_location(location_name).access_rule = rule # Register indirect conditions - multiworld.register_indirect_condition(world.get_region("Burial"), world.get_entrance("To Slide Room")) - multiworld.register_indirect_condition(world.get_region("Egypt"), world.get_entrance("To Slide Room")) - multiworld.register_indirect_condition(world.get_region("Gods Room"), world.get_entrance("To Slide Room")) - multiworld.register_indirect_condition(world.get_region("Prehistoric"), world.get_entrance("To Slide Room")) - multiworld.register_indirect_condition(world.get_region("Tar River"), world.get_entrance("To Slide Room")) - multiworld.register_indirect_condition(world.get_region("Werewolf"), world.get_entrance("To Slide Room")) multiworld.register_indirect_condition(world.get_region("Prehistoric"), world.get_entrance("To Tar River From Lobby")) # forbid cloth in janitor closet and oil in tar river - forbid_item(multiworld.get_location("Accessible: Storage: Janitor Closet", player), "Cloth Pot Bottom DUPE", player) - forbid_item(multiworld.get_location("Accessible: Storage: Janitor Closet", player), "Cloth Pot Top DUPE", player) - forbid_item(multiworld.get_location("Accessible: Storage: Janitor Closet", player), "Cloth Pot Complete DUPE", player) - forbid_item(multiworld.get_location("Accessible: Storage: Tar River", player), "Oil Pot Bottom DUPE", player) - forbid_item(multiworld.get_location("Accessible: Storage: Tar River", player), "Oil Pot Top DUPE", player) - forbid_item(multiworld.get_location("Accessible: Storage: Tar River", player), "Oil Pot Complete DUPE", player) + forbid_item(world.get_location("Storage: Janitor Closet"), "Cloth Pot Bottom DUPE", player) + forbid_item(world.get_location("Storage: Janitor Closet"), "Cloth Pot Top DUPE", player) + forbid_item(world.get_location("Storage: Janitor Closet"), "Cloth Pot Complete DUPE", player) + forbid_item(world.get_location("Storage: Tar River"), "Oil Pot Bottom DUPE", player) + forbid_item(world.get_location("Storage: Tar River"), "Oil Pot Top DUPE", player) + forbid_item(world.get_location("Storage: Tar River"), "Oil Pot Complete DUPE", player) # Filler Item Forbids - forbid_item(multiworld.get_location("Puzzle Solved Lyre", player), "Easier Lyre", player) - forbid_item(multiworld.get_location("Ixupi Captured Water", player), "Water Always Available in Lobby", player) - forbid_item(multiworld.get_location("Ixupi Captured Wax", player), "Wax Always Available in Library", player) - forbid_item(multiworld.get_location("Ixupi Captured Wax", player), "Wax Always Available in Anansi Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Wax", player), "Wax Always Available in Shaman Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Ash", player), "Ash Always Available in Office", player) - forbid_item(multiworld.get_location("Ixupi Captured Ash", player), "Ash Always Available in Burial Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Oil", player), "Oil Always Available in Prehistoric Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Cloth", player), "Cloth Always Available in Egypt", player) - forbid_item(multiworld.get_location("Ixupi Captured Cloth", player), "Cloth Always Available in Burial Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Wood", player), "Wood Always Available in Workshop", player) - forbid_item(multiworld.get_location("Ixupi Captured Wood", player), "Wood Always Available in Blue Maze", player) - forbid_item(multiworld.get_location("Ixupi Captured Wood", player), "Wood Always Available in Pegasus Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Wood", player), "Wood Always Available in Gods Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Crystal", player), "Crystal Always Available in Lobby", player) - forbid_item(multiworld.get_location("Ixupi Captured Crystal", player), "Crystal Always Available in Ocean", player) - forbid_item(multiworld.get_location("Ixupi Captured Sand", player), "Sand Always Available in Plants Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Sand", player), "Sand Always Available in Ocean", player) - forbid_item(multiworld.get_location("Ixupi Captured Metal", player), "Metal Always Available in Projector Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Metal", player), "Metal Always Available in Bedroom", player) - forbid_item(multiworld.get_location("Ixupi Captured Metal", player), "Metal Always Available in Prehistoric", player) + forbid_item(world.get_location("Puzzle Solved Lyre"), "Easier Lyre", player) + forbid_item(world.get_location("Ixupi Captured Water"), "Water Always Available in Lobby", player) + forbid_item(world.get_location("Ixupi Captured Wax"), "Wax Always Available in Library", player) + forbid_item(world.get_location("Ixupi Captured Wax"), "Wax Always Available in Anansi Room", player) + forbid_item(world.get_location("Ixupi Captured Wax"), "Wax Always Available in Shaman Room", player) + forbid_item(world.get_location("Ixupi Captured Ash"), "Ash Always Available in Office", player) + forbid_item(world.get_location("Ixupi Captured Ash"), "Ash Always Available in Burial Room", player) + forbid_item(world.get_location("Ixupi Captured Oil"), "Oil Always Available in Prehistoric Room", player) + forbid_item(world.get_location("Ixupi Captured Cloth"), "Cloth Always Available in Egypt", player) + forbid_item(world.get_location("Ixupi Captured Cloth"), "Cloth Always Available in Burial Room", player) + forbid_item(world.get_location("Ixupi Captured Wood"), "Wood Always Available in Workshop", player) + forbid_item(world.get_location("Ixupi Captured Wood"), "Wood Always Available in Blue Maze", player) + forbid_item(world.get_location("Ixupi Captured Wood"), "Wood Always Available in Pegasus Room", player) + forbid_item(world.get_location("Ixupi Captured Wood"), "Wood Always Available in Gods Room", player) + forbid_item(world.get_location("Ixupi Captured Crystal"), "Crystal Always Available in Lobby", player) + forbid_item(world.get_location("Ixupi Captured Crystal"), "Crystal Always Available in Ocean", player) + forbid_item(world.get_location("Ixupi Captured Sand"), "Sand Always Available in Plants Room", player) + forbid_item(world.get_location("Ixupi Captured Sand"), "Sand Always Available in Ocean", player) + forbid_item(world.get_location("Ixupi Captured Metal"), "Metal Always Available in Projector Room", player) + forbid_item(world.get_location("Ixupi Captured Metal"), "Metal Always Available in Bedroom", player) + forbid_item(world.get_location("Ixupi Captured Metal"), "Metal Always Available in Prehistoric", player) # Set completion condition - multiworld.completion_condition[player] = lambda state: (( - water_capturable(state, player) + wax_capturable(state, player) + ash_capturable(state, player) \ - + oil_capturable(state, player) + cloth_capturable(state, player) + wood_capturable(state, player) \ - + crystal_capturable(state, player) + sand_capturable(state, player) + metal_capturable(state, player) \ - + lightning_capturable(state, player)) >= world.options.ixupi_captures_needed.value) + multiworld.completion_condition[player] = lambda state: completion_condition(state, player) diff --git a/worlds/shivers/__init__.py b/worlds/shivers/__init__.py index 3ca87ae164f2..6a41dce376b3 100644 --- a/worlds/shivers/__init__.py +++ b/worlds/shivers/__init__.py @@ -1,11 +1,12 @@ -from typing import List -from .Items import item_table, ShiversItem -from .Rules import set_rules -from BaseClasses import Item, Tutorial, Region, Location +from typing import Dict, List, Optional + +from BaseClasses import Item, ItemClassification, Location, Region, Tutorial from Fill import fill_restrictive from worlds.AutoWorld import WebWorld, World from . import Constants, Rules -from .Options import ShiversOptions +from .Items import ItemType, SHIVERS_ITEM_ID_OFFSET, ShiversItem, item_table +from .Options import ShiversOptions, shivers_option_groups +from .Rules import set_rules class ShiversWeb(WebWorld): @@ -17,10 +18,13 @@ class ShiversWeb(WebWorld): "setup/en", ["GodlFire", "Mathx2"] )] + option_groups = shivers_option_groups + class ShiversWorld(World): """ - Shivers is a horror themed point and click adventure. Explore the mysteries of Windlenot's Museum of the Strange and Unusual. + Shivers is a horror themed point and click adventure. + Explore the mysteries of Windlenot's Museum of the Strange and Unusual. """ game = "Shivers" @@ -28,13 +32,12 @@ class ShiversWorld(World): web = ShiversWeb() options_dataclass = ShiversOptions options: ShiversOptions - + set_rules = set_rules item_name_to_id = {name: data.code for name, data in item_table.items()} location_name_to_id = Constants.location_name_to_id - shivers_item_id_offset = 27000 + storage_placements = [] pot_completed_list: List[int] - def generate_early(self): self.pot_completed_list = [] @@ -42,10 +45,14 @@ def create_item(self, name: str) -> Item: data = item_table[name] return ShiversItem(name, data.classification, data.code, self.player) - def create_event(self, region_name: str, event_name: str) -> None: - region = self.multiworld.get_region(region_name, self.player) - loc = ShiversLocation(self.player, event_name, None, region) - loc.place_locked_item(self.create_event_item(event_name)) + def create_event_location(self, region_name: str, location_name: str, event_name: Optional[str] = None) -> None: + region = self.get_region(region_name) + loc = ShiversLocation(self.player, location_name, None, region) + if event_name is not None: + loc.place_locked_item(ShiversItem(event_name, ItemClassification.progression, None, self.player)) + else: + loc.place_locked_item(ShiversItem(location_name, ItemClassification.progression, None, self.player)) + loc.show_in_spoiler = False region.locations.append(loc) def create_regions(self) -> None: @@ -56,162 +63,193 @@ def create_regions(self) -> None: for exit_name in exits: r.create_exit(exit_name) - # Bind mandatory connections for entr_name, region_name in Constants.region_info["mandatory_connections"]: - e = self.multiworld.get_entrance(entr_name, self.player) - r = self.multiworld.get_region(region_name, self.player) + e = self.get_entrance(entr_name) + r = self.get_region(region_name) e.connect(r) # Locations # Build exclusion list - self.removed_locations = set() + removed_locations = set() if not self.options.include_information_plaques: - self.removed_locations.update(Constants.exclusion_info["plaques"]) + removed_locations.update(Constants.exclusion_info["plaques"]) if not self.options.elevators_stay_solved: - self.removed_locations.update(Constants.exclusion_info["elevators"]) + removed_locations.update(Constants.exclusion_info["elevators"]) if not self.options.early_lightning: - self.removed_locations.update(Constants.exclusion_info["lightning"]) + removed_locations.update(Constants.exclusion_info["lightning"]) # Add locations for region_name, locations in Constants.location_info["locations_by_region"].items(): - region = self.multiworld.get_region(region_name, self.player) + region = self.get_region(region_name) for loc_name in locations: - if loc_name not in self.removed_locations: + if loc_name not in removed_locations: loc = ShiversLocation(self.player, loc_name, self.location_name_to_id.get(loc_name, None), region) region.locations.append(loc) + self.create_event_location("Prehistoric", "Set Skull Dial: Prehistoric") + self.create_event_location("Tar River", "Set Skull Dial: Tar River") + self.create_event_location("Egypt", "Set Skull Dial: Egypt") + self.create_event_location("Burial", "Set Skull Dial: Burial") + self.create_event_location("Gods Room", "Set Skull Dial: Gods Room") + self.create_event_location("Werewolf", "Set Skull Dial: Werewolf") + self.create_event_location("Projector Room", "Viewed Theater Movie") + self.create_event_location("Clock Chains", "Clock Chains", "Set Time") + self.create_event_location("Clock Tower", "Jukebox", "Set Song") + self.create_event_location("Fortune Teller", "Viewed Fortune") + self.create_event_location("Orrery", "Orrery", "Aligned Planets") + self.create_event_location("Norse Stone", "Norse Stone", "Viewed Norse Stone") + self.create_event_location("Beth's Body", "Beth's Body", "Viewed Page 17") + self.create_event_location("Windlenot's Body", "Windlenot's Body", "Viewed Egyptian Hieroglyphics Explained") + self.create_event_location("Guillotine", "Guillotine", "Lost Your Head") + def create_items(self) -> None: - #Add items to item pool - itempool = [] + # Add items to item pool + item_pool = [] for name, data in item_table.items(): - if data.type in {"key", "ability", "filler2"}: - itempool.append(self.create_item(name)) + if data.type in [ItemType.KEY, ItemType.ABILITY, ItemType.IXUPI_AVAILABILITY]: + item_pool.append(self.create_item(name)) # Pot pieces/Completed/Mixed: - for i in range(10): - if self.options.full_pots == "pieces": - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + i])) - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 10 + i])) - elif self.options.full_pots == "complete": - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 20 + i])) - else: - # Roll for if pieces or a complete pot will be used. - # Pot Pieces + if self.options.full_pots == "pieces": + item_pool += [self.create_item(name) for name, data in item_table.items() if data.type == ItemType.POT] + elif self.options.full_pots == "complete": + item_pool += [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_COMPLETE] + else: + # Roll for if pieces or a complete pot will be used. + # Pot Pieces + pieces = [self.create_item(name) for name, data in item_table.items() if data.type == ItemType.POT] + complete = [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_COMPLETE] + for i in range(10): if self.random.randint(0, 1) == 0: self.pot_completed_list.append(0) - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + i])) - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 10 + i])) + item_pool.append(pieces[i]) + item_pool.append(pieces[i + 10]) # Completed Pot else: self.pot_completed_list.append(1) - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 20 + i])) - - #Add Filler - itempool += [self.create_item("Easier Lyre") for i in range(9)] + item_pool.append(complete[i]) - #Extra filler is random between Heals and Easier Lyre. Heals weighted 95%. - filler_needed = len(self.multiworld.get_unfilled_locations(self.player)) - 24 - len(itempool) - itempool += [self.random.choices([self.create_item("Heal"), self.create_item("Easier Lyre")], weights=[95, 5])[0] for i in range(filler_needed)] + # Add Easier Lyre + item_pool += [self.create_item("Easier Lyre") for _ in range(9)] - #Place library escape items. Choose a location to place the escape item - library_region = self.multiworld.get_region("Library", self.player) - librarylocation = self.random.choice([loc for loc in library_region.locations if not loc.name.startswith("Accessible:")]) + # Place library escape items. Choose a location to place the escape item + library_region = self.get_region("Library") + library_location = self.random.choice( + [loc for loc in library_region.locations if not loc.name.startswith("Storage: ")] + ) - #Roll for which escape items will be placed in the Library + # Roll for which escape items will be placed in the Library library_random = self.random.randint(1, 3) - if library_random == 1: - librarylocation.place_locked_item(self.create_item("Crawling")) - - itempool = [item for item in itempool if item.name != "Crawling"] - - elif library_random == 2: - librarylocation.place_locked_item(self.create_item("Key for Library Room")) - - itempool = [item for item in itempool if item.name != "Key for Library Room"] - elif library_random == 3: - librarylocation.place_locked_item(self.create_item("Key for Three Floor Elevator")) - - librarylocationkeytwo = self.random.choice([loc for loc in library_region.locations if not loc.name.startswith("Accessible:") and loc != librarylocation]) - librarylocationkeytwo.place_locked_item(self.create_item("Key for Egypt Room")) - - itempool = [item for item in itempool if item.name not in ["Key for Three Floor Elevator", "Key for Egypt Room"]] - - #If front door option is on, determine which set of keys will be used for lobby access and add front door key to item pool - lobby_access_keys = 1 + if library_random == 1: + library_location.place_locked_item(self.create_item("Crawling")) + item_pool = [item for item in item_pool if item.name != "Crawling"] + elif library_random == 2: + library_location.place_locked_item(self.create_item("Key for Library")) + item_pool = [item for item in item_pool if item.name != "Key for Library"] + elif library_random == 3: + library_location.place_locked_item(self.create_item("Key for Three Floor Elevator")) + library_location_2 = self.random.choice( + [loc for loc in library_region.locations if + not loc.name.startswith("Storage: ") and loc != library_location] + ) + library_location_2.place_locked_item(self.create_item("Key for Egypt Room")) + item_pool = [item for item in item_pool if + item.name not in ["Key for Three Floor Elevator", "Key for Egypt Room"]] + + # If front door option is on, determine which set of keys will + # be used for lobby access and add front door key to item pool + lobby_access_keys = 0 if self.options.front_door_usable: - lobby_access_keys = self.random.randint(1, 2) - itempool += [self.create_item("Key for Front Door")] + lobby_access_keys = self.random.randint(0, 1) + item_pool.append(self.create_item("Key for Front Door")) else: - itempool += [self.create_item("Heal")] + item_pool.append(self.create_item("Heal")) - self.multiworld.itempool += itempool + def set_lobby_access_keys(items: Dict[str, int]): + if lobby_access_keys == 0: + items["Key for Underground Lake"] = 1 + items["Key for Office Elevator"] = 1 + items["Key for Office"] = 1 + else: + items["Key for Front Door"] = 1 - #Lobby acess: + # Lobby access: if self.options.lobby_access == "early": - if lobby_access_keys == 1: - self.multiworld.early_items[self.player]["Key for Underground Lake Room"] = 1 - self.multiworld.early_items[self.player]["Key for Office Elevator"] = 1 - self.multiworld.early_items[self.player]["Key for Office"] = 1 - elif lobby_access_keys == 2: - self.multiworld.early_items[self.player]["Key for Front Door"] = 1 - if self.options.lobby_access == "local": - if lobby_access_keys == 1: - self.multiworld.local_early_items[self.player]["Key for Underground Lake Room"] = 1 - self.multiworld.local_early_items[self.player]["Key for Office Elevator"] = 1 - self.multiworld.local_early_items[self.player]["Key for Office"] = 1 - elif lobby_access_keys == 2: - self.multiworld.local_early_items[self.player]["Key for Front Door"] = 1 - - #Pot piece shuffle location: + set_lobby_access_keys(self.multiworld.early_items[self.player]) + elif self.options.lobby_access == "local": + set_lobby_access_keys(self.multiworld.local_early_items[self.player]) + + goal_item_code = SHIVERS_ITEM_ID_OFFSET + 100 + Constants.years_since_sep_30_1980 + for name, data in item_table.items(): + if data.type == ItemType.GOAL and data.code == goal_item_code: + goal = self.create_item(name) + self.get_location("Mystery Solved").place_locked_item(goal) + + # Extra filler is random between Heals and Easier Lyre. Heals weighted 95%. + filler_needed = len(self.multiworld.get_unfilled_locations(self.player)) - len(item_pool) - 23 + item_pool += map(self.create_item, self.random.choices( + ["Heal", "Easier Lyre"], weights=[95, 5], k=filler_needed + )) + + # Pot piece shuffle location: if self.options.location_pot_pieces == "own_world": - self.options.local_items.value |= {name for name, data in item_table.items() if data.type == "pot" or data.type == "pot_type2"} - if self.options.location_pot_pieces == "different_world": - self.options.non_local_items.value |= {name for name, data in item_table.items() if data.type == "pot" or data.type == "pot_type2"} + self.options.local_items.value |= {name for name, data in item_table.items() if + data.type in [ItemType.POT, ItemType.POT_COMPLETE]} + elif self.options.location_pot_pieces == "different_world": + self.options.non_local_items.value |= {name for name, data in item_table.items() if + data.type in [ItemType.POT, ItemType.POT_COMPLETE]} + + self.multiworld.itempool += item_pool def pre_fill(self) -> None: # Prefills event storage locations with duplicate pots - storagelocs = [] - storageitems = [] - self.storage_placements = [] + storage_locs = [] + storage_items = [] for locations in Constants.location_info["locations_by_region"].values(): for loc_name in locations: - if loc_name.startswith("Accessible: "): - storagelocs.append(self.multiworld.get_location(loc_name, self.player)) + if loc_name.startswith("Storage: "): + storage_locs.append(self.get_location(loc_name)) - #Pot pieces/Completed/Mixed: + # Pot pieces/Completed/Mixed: if self.options.full_pots == "pieces": - storageitems += [self.create_item(name) for name, data in item_table.items() if data.type == 'potduplicate'] + storage_items += [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_DUPLICATE] elif self.options.full_pots == "complete": - storageitems += [self.create_item(name) for name, data in item_table.items() if data.type == 'potduplicate_type2'] - storageitems += [self.create_item("Empty") for i in range(10)] + storage_items += [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_COMPELTE_DUPLICATE] + storage_items += [self.create_item("Empty") for _ in range(10)] else: + pieces = [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_DUPLICATE] + complete = [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_COMPELTE_DUPLICATE] for i in range(10): - #Pieces + # Pieces if self.pot_completed_list[i] == 0: - storageitems += [self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 70 + i])] - storageitems += [self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 80 + i])] - #Complete + storage_items.append(pieces[i]) + storage_items.append(pieces[i + 10]) + # Complete else: - storageitems += [self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 140 + i])] - storageitems += [self.create_item("Empty")] + storage_items.append(complete[i]) + storage_items.append(self.create_item("Empty")) - storageitems += [self.create_item("Empty") for i in range(3)] + storage_items += [self.create_item("Empty") for _ in range(3)] state = self.multiworld.get_all_state(True) - self.random.shuffle(storagelocs) - self.random.shuffle(storageitems) - - fill_restrictive(self.multiworld, state, storagelocs.copy(), storageitems, True, True) + self.random.shuffle(storage_locs) + self.random.shuffle(storage_items) - self.storage_placements = {location.name: location.item.name for location in storagelocs} + fill_restrictive(self.multiworld, state, storage_locs.copy(), storage_items, True, True) - set_rules = set_rules + self.storage_placements = {location.name.replace("Storage: ", ""): location.item.name.replace(" DUPE", "") for + location in storage_locs} def fill_slot_data(self) -> dict: - return { "StoragePlacements": self.storage_placements, "ExcludedLocations": list(self.options.exclude_locations.value), diff --git a/worlds/shivers/data/excluded_locations.json b/worlds/shivers/data/excluded_locations.json index 29655d4a5024..1f012964cc61 100644 --- a/worlds/shivers/data/excluded_locations.json +++ b/worlds/shivers/data/excluded_locations.json @@ -11,7 +11,7 @@ "Information Plaque: (Ocean) Poseidon", "Information Plaque: (Ocean) Colossus of Rhodes", "Information Plaque: (Ocean) Poseidon's Temple", - "Information Plaque: (Underground Maze) Subterranean World", + "Information Plaque: (Underground Maze Staircase) Subterranean World", "Information Plaque: (Underground Maze) Dero", "Information Plaque: (Egypt) Tomb of the Ixupi", "Information Plaque: (Egypt) The Sphinx", diff --git a/worlds/shivers/data/locations.json b/worlds/shivers/data/locations.json index 64fe3647348d..41fe517061a8 100644 --- a/worlds/shivers/data/locations.json +++ b/worlds/shivers/data/locations.json @@ -19,7 +19,7 @@ "Puzzle Solved Fortune Teller Door", "Puzzle Solved Alchemy", "Puzzle Solved UFO Symbols", - "Puzzle Solved Anansi Musicbox", + "Puzzle Solved Anansi Music Box", "Puzzle Solved Gallows", "Puzzle Solved Mastermind", "Puzzle Solved Marble Flipper", @@ -54,7 +54,7 @@ "Final Riddle: Norse God Stone Message", "Final Riddle: Beth's Body Page 17", "Final Riddle: Guillotine Dropped", - "Puzzle Hint Found: Combo Lock in Mailbox", + "Puzzle Hint Found: Mailbox", "Puzzle Hint Found: Orange Symbol", "Puzzle Hint Found: Silver Symbol", "Puzzle Hint Found: Green Symbol", @@ -113,15 +113,19 @@ "Puzzle Solved Office Elevator", "Puzzle Solved Bedroom Elevator", "Puzzle Solved Three Floor Elevator", - "Ixupi Captured Lightning" + "Ixupi Captured Lightning", + "Puzzle Solved Combination Lock", + "Puzzle Hint Found: Beth's Note", + "Mystery Solved" ], "locations_by_region": { "Outside": [ + "Puzzle Solved Combination Lock", "Puzzle Solved Gears", "Puzzle Solved Stone Henge", "Puzzle Solved Office Elevator", "Puzzle Solved Three Floor Elevator", - "Puzzle Hint Found: Combo Lock in Mailbox", + "Puzzle Hint Found: Mailbox", "Puzzle Hint Found: Orange Symbol", "Puzzle Hint Found: Silver Symbol", "Puzzle Hint Found: Green Symbol", @@ -130,32 +134,42 @@ "Puzzle Hint Found: Tan Symbol" ], "Underground Lake": [ - "Flashback Memory Obtained Windlenot's Ghost", + "Flashback Memory Obtained Windlenot's Ghost" + ], + "Windlenot's Body": [ "Flashback Memory Obtained Egyptian Hieroglyphics Explained" ], "Office": [ "Flashback Memory Obtained Scrapbook", - "Accessible: Storage: Desk Drawer", + "Storage: Desk Drawer", "Puzzle Hint Found: Atlantis Map", "Puzzle Hint Found: Tape Recorder Heard", "Puzzle Solved Bedroom Elevator" ], "Workshop": [ "Puzzle Solved Workshop Drawers", - "Accessible: Storage: Workshop Drawers", + "Storage: Workshop Drawers", "Puzzle Hint Found: Basilisk Bone Fragments" ], "Bedroom": [ "Flashback Memory Obtained Professor Windlenot's Diary" ], + "Lobby": [ + "Puzzle Solved Theater Door", + "Flashback Memory Obtained Museum Brochure", + "Information Plaque: (Lobby) Jade Skull", + "Information Plaque: (Lobby) Transforming Masks", + "Storage: Slide", + "Storage: Transforming Mask" + ], "Library": [ "Puzzle Solved Library Statue", "Flashback Memory Obtained In Search of the Unexplained", "Flashback Memory Obtained South American Pictographs", "Flashback Memory Obtained Mythology of the Stars", "Flashback Memory Obtained Black Book", - "Accessible: Storage: Library Cabinet", - "Accessible: Storage: Library Statue" + "Storage: Library Cabinet", + "Storage: Library Statue" ], "Maintenance Tunnels": [ "Flashback Memory Obtained Beth's Address Book" @@ -163,37 +177,46 @@ "Three Floor Elevator": [ "Puzzle Hint Found: Elevator Writing" ], - "Lobby": [ - "Puzzle Solved Theater Door", - "Flashback Memory Obtained Museum Brochure", - "Information Plaque: (Lobby) Jade Skull", - "Information Plaque: (Lobby) Transforming Masks", - "Accessible: Storage: Slide", - "Accessible: Storage: Transforming Mask" - ], "Generator": [ - "Final Riddle: Beth's Body Page 17", "Ixupi Captured Lightning" ], - "Theater Back Hallways": [ + "Beth's Body": [ + "Final Riddle: Beth's Body Page 17" + ], + "Theater": [ + "Storage: Theater", + "Puzzle Hint Found: Beth's Note" + ], + "Theater Back Hallway": [ "Puzzle Solved Clock Tower Door" ], - "Clock Tower Staircase": [ + "Clock Chains": [ "Puzzle Solved Clock Chains" ], "Clock Tower": [ "Flashback Memory Obtained Beth's Ghost", - "Accessible: Storage: Clock Tower", + "Storage: Clock Tower", "Puzzle Hint Found: Shaman Security Camera" ], "Projector Room": [ "Flashback Memory Obtained Theater Movie" ], + "Prehistoric": [ + "Information Plaque: (Prehistoric) Bronze Unicorn", + "Information Plaque: (Prehistoric) Griffin", + "Information Plaque: (Prehistoric) Eagles Nest", + "Information Plaque: (Prehistoric) Large Spider", + "Information Plaque: (Prehistoric) Starfish", + "Storage: Eagles Nest" + ], + "Greenhouse": [ + "Storage: Greenhouse" + ], "Ocean": [ "Puzzle Solved Atlantis", "Puzzle Solved Organ", "Flashback Memory Obtained Museum Blueprints", - "Accessible: Storage: Ocean", + "Storage: Ocean", "Puzzle Hint Found: Sirens Song Heard", "Information Plaque: (Ocean) Quartz Crystal", "Information Plaque: (Ocean) Poseidon", @@ -204,10 +227,14 @@ "Information Plaque: (Underground Maze Staircase) Subterranean World", "Puzzle Solved Maze Door" ], + "Tar River": [ + "Storage: Tar River", + "Information Plaque: (Underground Maze) Dero" + ], "Egypt": [ "Puzzle Solved Columns of RA", "Puzzle Solved Burial Door", - "Accessible: Storage: Egypt", + "Storage: Egypt", "Puzzle Hint Found: Egyptian Sphinx Heard", "Information Plaque: (Egypt) Tomb of the Ixupi", "Information Plaque: (Egypt) The Sphinx", @@ -216,7 +243,7 @@ "Burial": [ "Puzzle Solved Chinese Solitaire", "Flashback Memory Obtained Merrick's Notebook", - "Accessible: Storage: Chinese Solitaire", + "Storage: Chinese Solitaire", "Information Plaque: (Burial) Norse Burial Ship", "Information Plaque: (Burial) Paracas Burial Bundles", "Information Plaque: (Burial) Spectacular Coffins of Ghana", @@ -225,15 +252,14 @@ ], "Shaman": [ "Puzzle Solved Shaman Drums", - "Accessible: Storage: Shaman Hut", + "Storage: Shaman Hut", "Information Plaque: (Shaman) Witch Doctors of the Congo", "Information Plaque: (Shaman) Sarombe doctor of Mozambique" ], "Gods Room": [ "Puzzle Solved Lyre", "Puzzle Solved Red Door", - "Accessible: Storage: Lyre", - "Final Riddle: Norse God Stone Message", + "Storage: Lyre", "Information Plaque: (Gods) Fisherman's Canoe God", "Information Plaque: (Gods) Mayan Gods", "Information Plaque: (Gods) Thor", @@ -242,6 +268,9 @@ "Information Plaque: (Gods) Sumerian Lyre", "Information Plaque: (Gods) Chuen" ], + "Norse Stone": [ + "Final Riddle: Norse God Stone Message" + ], "Blue Maze": [ "Puzzle Solved Fortune Teller Door" ], @@ -251,35 +280,46 @@ ], "Inventions": [ "Puzzle Solved Alchemy", - "Accessible: Storage: Alchemy" + "Storage: Alchemy" ], "UFO": [ "Puzzle Solved UFO Symbols", - "Accessible: Storage: UFO", - "Final Riddle: Planets Aligned", + "Storage: UFO", "Information Plaque: (UFO) Coincidence or Extraterrestrial Visits?", "Information Plaque: (UFO) Planets", "Information Plaque: (UFO) Astronomical Construction", "Information Plaque: (UFO) Aliens" ], + "Orrery": [ + "Final Riddle: Planets Aligned" + ], + "Janitor Closet": [ + "Storage: Janitor Closet" + ], + "Werewolf": [ + "Information Plaque: (Werewolf) Lycanthropy" + ], + "Pegasus": [ + "Information Plaque: (Pegasus) Cyclops" + ], "Anansi": [ - "Puzzle Solved Anansi Musicbox", + "Puzzle Solved Anansi Music Box", "Flashback Memory Obtained Ancient Astrology", - "Accessible: Storage: Skeleton", - "Accessible: Storage: Anansi", + "Storage: Skeleton", + "Storage: Anansi Music Box", "Information Plaque: (Anansi) African Creation Myth", "Information Plaque: (Anansi) Apophis the Serpent", - "Information Plaque: (Anansi) Death", - "Information Plaque: (Pegasus) Cyclops", - "Information Plaque: (Werewolf) Lycanthropy" + "Information Plaque: (Anansi) Death" ], "Torture": [ "Puzzle Solved Gallows", - "Accessible: Storage: Gallows", - "Final Riddle: Guillotine Dropped", + "Storage: Gallows", "Puzzle Hint Found: Gallows Information Plaque", "Information Plaque: (Torture) Guillotine" ], + "Guillotine": [ + "Final Riddle: Guillotine Dropped" + ], "Puzzle Room Mastermind": [ "Puzzle Solved Mastermind", "Puzzle Hint Found: Mastermind Information Plaque" @@ -287,29 +327,8 @@ "Puzzle Room Marbles": [ "Puzzle Solved Marble Flipper" ], - "Prehistoric": [ - "Information Plaque: (Prehistoric) Bronze Unicorn", - "Information Plaque: (Prehistoric) Griffin", - "Information Plaque: (Prehistoric) Eagles Nest", - "Information Plaque: (Prehistoric) Large Spider", - "Information Plaque: (Prehistoric) Starfish", - "Accessible: Storage: Eagles Nest" - ], - "Tar River": [ - "Accessible: Storage: Tar River", - "Information Plaque: (Underground Maze) Dero" - ], - "Theater": [ - "Accessible: Storage: Theater" - ], - "Greenhouse": [ - "Accessible: Storage: Greenhouse" - ], - "Janitor Closet": [ - "Accessible: Storage: Janitor Closet" - ], - "Skull Dial Bridge": [ - "Accessible: Storage: Skull Bridge", + "Skull Bridge": [ + "Storage: Skull Bridge", "Puzzle Solved Skull Dial Door" ], "Water Capture": [ @@ -338,6 +357,9 @@ ], "Metal Capture": [ "Ixupi Captured Metal" + ], + "Victory": [ + "Mystery Solved" ] } } diff --git a/worlds/shivers/data/regions.json b/worlds/shivers/data/regions.json index aeb5aa737366..36eaa7874cb9 100644 --- a/worlds/shivers/data/regions.json +++ b/worlds/shivers/data/regions.json @@ -4,22 +4,25 @@ ["Registry", ["To Outside From Registry"]], ["Outside", ["To Underground Tunnels From Outside", "To Lobby From Outside"]], ["Underground Tunnels", ["To Underground Lake From Underground Tunnels", "To Outside From Underground"]], - ["Underground Lake", ["To Underground Tunnels From Underground Lake", "To Underground Blue Tunnels From Underground Lake"]], + ["Underground Lake", ["To Underground Tunnels From Underground Lake", "To Windlenot's Body From Underground Lake", "To Underground Blue Tunnels From Underground Lake"]], + ["Windlenot's Body", ["To Underground Lake From Windlenot's Body"]], ["Underground Blue Tunnels", ["To Underground Lake From Underground Blue Tunnels", "To Office Elevator From Underground Blue Tunnels"]], ["Office Elevator", ["To Underground Blue Tunnels From Office Elevator","To Office From Office Elevator"]], ["Office", ["To Office Elevator From Office", "To Workshop", "To Lobby From Office", "To Bedroom Elevator From Office", "To Ash Capture From Office"]], ["Workshop", ["To Office From Workshop", "To Wood Capture From Workshop"]], ["Bedroom Elevator", ["To Office From Bedroom Elevator", "To Bedroom"]], ["Bedroom", ["To Bedroom Elevator From Bedroom", "To Metal Capture From Bedroom"]], - ["Lobby", ["To Office From Lobby", "To Library From Lobby", "To Theater From Lobby", "To Prehistoric From Lobby", "To Egypt From Lobby", "To Tar River From Lobby", "To Outside From Lobby", "To Water Capture From Lobby", "To Crystal Capture From Lobby"]], + ["Lobby", ["To Office From Lobby", "To Library From Lobby", "To Theater From Lobby", "To Prehistoric From Lobby", "To Egypt From Lobby", "To Tar River From Lobby", "To Outside From Lobby", "To Water Capture From Lobby", "To Crystal Capture From Lobby", "To Victory"]], ["Library", ["To Lobby From Library", "To Maintenance Tunnels From Library", "To Wax Capture From Library"]], - ["Maintenance Tunnels", ["To Library From Maintenance Tunnels", "To Three Floor Elevator From Maintenance Tunnels", "To Generator"]], + ["Maintenance Tunnels", ["To Library From Maintenance Tunnels", "To Three Floor Elevator From Maintenance Tunnels", "To Generator From Maintenance Tunnels"]], ["Generator", ["To Maintenance Tunnels From Generator"]], - ["Theater", ["To Lobby From Theater", "To Theater Back Hallways From Theater"]], - ["Theater Back Hallways", ["To Theater From Theater Back Hallways", "To Clock Tower Staircase From Theater Back Hallways", "To Maintenance Tunnels From Theater Back Hallways", "To Projector Room"]], - ["Clock Tower Staircase", ["To Theater Back Hallways From Clock Tower Staircase", "To Clock Tower"]], + ["Beth's Body", ["To Generator From Beth's Body"]], + ["Theater", ["To Lobby From Theater", "To Theater Back Hallway From Theater"]], + ["Theater Back Hallway", ["To Theater From Theater Back Hallway", "To Clock Tower Staircase From Theater Back Hallway", "To Maintenance Tunnels From Theater Back Hallway", "To Projector Room"]], + ["Clock Tower Staircase", ["To Theater Back Hallway From Clock Tower Staircase", "To Clock Tower"]], + ["Clock Chains", ["To Clock Tower Staircase From Clock Chains"]], ["Clock Tower", ["To Clock Tower Staircase From Clock Tower"]], - ["Projector Room", ["To Theater Back Hallways From Projector Room", "To Metal Capture From Projector Room"]], + ["Projector Room", ["To Theater Back Hallway From Projector Room", "To Metal Capture From Projector Room"]], ["Prehistoric", ["To Lobby From Prehistoric", "To Greenhouse", "To Ocean From Prehistoric", "To Oil Capture From Prehistoric", "To Metal Capture From Prehistoric"]], ["Greenhouse", ["To Prehistoric From Greenhouse", "To Sand Capture From Greenhouse"]], ["Ocean", ["To Prehistoric From Ocean", "To Maze Staircase From Ocean", "To Crystal Capture From Ocean", "To Sand Capture From Ocean"]], @@ -28,22 +31,26 @@ ["Tar River", ["To Maze From Tar River", "To Lobby From Tar River", "To Oil Capture From Tar River"]], ["Egypt", ["To Lobby From Egypt", "To Burial From Egypt", "To Blue Maze From Egypt", "To Cloth Capture From Egypt"]], ["Burial", ["To Egypt From Burial", "To Shaman From Burial", "To Ash Capture From Burial", "To Cloth Capture From Burial"]], - ["Shaman", ["To Burial From Shaman", "To Gods Room", "To Wax Capture From Shaman"]], - ["Gods Room", ["To Shaman From Gods Room", "To Anansi From Gods Room", "To Wood Capture From Gods Room"]], - ["Anansi", ["To Gods Room From Anansi", "To Werewolf From Anansi", "To Wax Capture From Anansi", "To Wood Capture From Anansi"]], - ["Werewolf", ["To Anansi From Werewolf", "To Night Staircase From Werewolf"]], - ["Night Staircase", ["To Werewolf From Night Staircase", "To Janitor Closet", "To UFO"]], + ["Shaman", ["To Burial From Shaman", "To Gods Room From Shaman", "To Wax Capture From Shaman"]], + ["Gods Room", ["To Shaman From Gods Room", "To Anansi From Gods Room", "To Wood Capture From Gods Room", "To Norse Stone From Gods Room"]], + ["Norse Stone", ["To Gods Room From Norse Stone"]], + ["Anansi", ["To Gods Room From Anansi", "To Pegasus From Anansi", "To Wax Capture From Anansi"]], + ["Pegasus", ["To Anansi From Pegasus", "To Werewolf From Pegasus", "To Wood Capture From Pegasus"]], + ["Werewolf", ["To Pegasus From Werewolf", "To Night Staircase From Werewolf"]], + ["Night Staircase", ["To Werewolf From Night Staircase", "To Janitor Closet", "To UFO From Night Staircase"]], ["Janitor Closet", ["To Night Staircase From Janitor Closet", "To Water Capture From Janitor Closet", "To Cloth Capture From Janitor Closet"]], - ["UFO", ["To Night Staircase From UFO", "To Inventions From UFO"]], + ["UFO", ["To Night Staircase From UFO", "To Orrery From UFO", "To Inventions From UFO"]], + ["Orrery", ["To UFO From Orrery"]], ["Blue Maze", ["To Egypt From Blue Maze", "To Three Floor Elevator From Blue Maze Bottom", "To Three Floor Elevator From Blue Maze Top", "To Fortune Teller", "To Inventions From Blue Maze", "To Wood Capture From Blue Maze"]], ["Three Floor Elevator", ["To Maintenance Tunnels From Three Floor Elevator", "To Blue Maze From Three Floor Elevator"]], ["Fortune Teller", ["To Blue Maze From Fortune Teller"]], ["Inventions", ["To Blue Maze From Inventions", "To UFO From Inventions", "To Torture From Inventions"]], ["Torture", ["To Inventions From Torture", "To Puzzle Room Mastermind From Torture"]], + ["Guillotine", ["To Torture From Guillotine"]], ["Puzzle Room Mastermind", ["To Torture", "To Puzzle Room Marbles From Puzzle Room Mastermind"]], - ["Puzzle Room Marbles", ["To Puzzle Room Mastermind From Puzzle Room Marbles", "To Skull Dial Bridge From Puzzle Room Marbles"]], - ["Skull Dial Bridge", ["To Puzzle Room Marbles From Skull Dial Bridge", "To Slide Room"]], - ["Slide Room", ["To Skull Dial Bridge From Slide Room", "To Lobby From Slide Room"]], + ["Puzzle Room Marbles", ["To Puzzle Room Mastermind From Puzzle Room Marbles", "To Skull Bridge From Puzzle Room Marbles"]], + ["Skull Bridge", ["To Puzzle Room Marbles From Skull Bridge", "To Slide Room"]], + ["Slide Room", ["To Skull Bridge From Slide Room", "To Lobby From Slide Room"]], ["Water Capture", []], ["Wax Capture", []], ["Ash Capture", []], @@ -52,17 +59,20 @@ ["Wood Capture", []], ["Crystal Capture", []], ["Sand Capture", []], - ["Metal Capture", []] + ["Metal Capture", []], + ["Victory", []] ], "mandatory_connections": [ - ["To Registry", "Registry"], + ["To Registry", "Registry"], ["To Outside From Registry", "Outside"], ["To Outside From Underground", "Outside"], ["To Outside From Lobby", "Outside"], ["To Underground Tunnels From Outside", "Underground Tunnels"], ["To Underground Tunnels From Underground Lake", "Underground Tunnels"], ["To Underground Lake From Underground Tunnels", "Underground Lake"], + ["To Underground Lake From Windlenot's Body", "Underground Lake"], ["To Underground Lake From Underground Blue Tunnels", "Underground Lake"], + ["To Windlenot's Body From Underground Lake", "Windlenot's Body"], ["To Underground Blue Tunnels From Underground Lake", "Underground Blue Tunnels"], ["To Underground Blue Tunnels From Office Elevator", "Underground Blue Tunnels"], ["To Office Elevator From Underground Blue Tunnels", "Office Elevator"], @@ -86,7 +96,7 @@ ["To Library From Lobby", "Library"], ["To Library From Maintenance Tunnels", "Library"], ["To Theater From Lobby", "Theater" ], - ["To Theater From Theater Back Hallways", "Theater"], + ["To Theater From Theater Back Hallway", "Theater"], ["To Prehistoric From Lobby", "Prehistoric"], ["To Prehistoric From Greenhouse", "Prehistoric"], ["To Prehistoric From Ocean", "Prehistoric"], @@ -96,15 +106,17 @@ ["To Maintenance Tunnels From Generator", "Maintenance Tunnels"], ["To Maintenance Tunnels From Three Floor Elevator", "Maintenance Tunnels"], ["To Maintenance Tunnels From Library", "Maintenance Tunnels"], - ["To Maintenance Tunnels From Theater Back Hallways", "Maintenance Tunnels"], + ["To Maintenance Tunnels From Theater Back Hallway", "Maintenance Tunnels"], ["To Three Floor Elevator From Maintenance Tunnels", "Three Floor Elevator"], ["To Three Floor Elevator From Blue Maze Bottom", "Three Floor Elevator"], ["To Three Floor Elevator From Blue Maze Top", "Three Floor Elevator"], - ["To Generator", "Generator"], - ["To Theater Back Hallways From Theater", "Theater Back Hallways"], - ["To Theater Back Hallways From Clock Tower Staircase", "Theater Back Hallways"], - ["To Theater Back Hallways From Projector Room", "Theater Back Hallways"], - ["To Clock Tower Staircase From Theater Back Hallways", "Clock Tower Staircase"], + ["To Generator From Maintenance Tunnels", "Generator"], + ["To Generator From Beth's Body", "Generator"], + ["To Theater Back Hallway From Theater", "Theater Back Hallway"], + ["To Theater Back Hallway From Clock Tower Staircase", "Theater Back Hallway"], + ["To Theater Back Hallway From Projector Room", "Theater Back Hallway"], + ["To Clock Tower Staircase From Theater Back Hallway", "Clock Tower Staircase"], + ["To Clock Tower Staircase From Clock Chains", "Clock Tower Staircase"], ["To Clock Tower Staircase From Clock Tower", "Clock Tower Staircase"], ["To Projector Room", "Projector Room"], ["To Clock Tower", "Clock Tower"], @@ -125,30 +137,37 @@ ["To Blue Maze From Egypt", "Blue Maze"], ["To Shaman From Burial", "Shaman"], ["To Shaman From Gods Room", "Shaman"], - ["To Gods Room", "Gods Room" ], + ["To Gods Room From Shaman", "Gods Room" ], + ["To Gods Room From Norse Stone", "Gods Room" ], ["To Gods Room From Anansi", "Gods Room"], + ["To Norse Stone From Gods Room", "Norse Stone" ], ["To Anansi From Gods Room", "Anansi"], - ["To Anansi From Werewolf", "Anansi"], - ["To Werewolf From Anansi", "Werewolf"], + ["To Anansi From Pegasus", "Anansi"], + ["To Pegasus From Anansi", "Pegasus"], + ["To Pegasus From Werewolf", "Pegasus"], + ["To Werewolf From Pegasus", "Werewolf"], ["To Werewolf From Night Staircase", "Werewolf"], ["To Night Staircase From Werewolf", "Night Staircase"], ["To Night Staircase From Janitor Closet", "Night Staircase"], ["To Night Staircase From UFO", "Night Staircase"], ["To Janitor Closet", "Janitor Closet"], - ["To UFO", "UFO"], + ["To UFO From Night Staircase", "UFO"], + ["To UFO From Orrery", "UFO"], ["To UFO From Inventions", "UFO"], + ["To Orrery From UFO", "Orrery"], ["To Inventions From UFO", "Inventions"], ["To Inventions From Blue Maze", "Inventions"], ["To Inventions From Torture", "Inventions"], ["To Fortune Teller", "Fortune Teller"], ["To Torture", "Torture"], + ["To Torture From Guillotine", "Torture"], ["To Torture From Inventions", "Torture"], ["To Puzzle Room Mastermind From Torture", "Puzzle Room Mastermind"], ["To Puzzle Room Mastermind From Puzzle Room Marbles", "Puzzle Room Mastermind"], ["To Puzzle Room Marbles From Puzzle Room Mastermind", "Puzzle Room Marbles"], - ["To Puzzle Room Marbles From Skull Dial Bridge", "Puzzle Room Marbles"], - ["To Skull Dial Bridge From Puzzle Room Marbles", "Skull Dial Bridge"], - ["To Skull Dial Bridge From Slide Room", "Skull Dial Bridge"], + ["To Puzzle Room Marbles From Skull Bridge", "Puzzle Room Marbles"], + ["To Skull Bridge From Puzzle Room Marbles", "Skull Bridge"], + ["To Skull Bridge From Slide Room", "Skull Bridge"], ["To Slide Room", "Slide Room"], ["To Wax Capture From Library", "Wax Capture"], ["To Wax Capture From Shaman", "Wax Capture"], @@ -164,7 +183,7 @@ ["To Cloth Capture From Janitor Closet", "Cloth Capture"], ["To Wood Capture From Workshop", "Wood Capture"], ["To Wood Capture From Gods Room", "Wood Capture"], - ["To Wood Capture From Anansi", "Wood Capture"], + ["To Wood Capture From Pegasus", "Wood Capture"], ["To Wood Capture From Blue Maze", "Wood Capture"], ["To Crystal Capture From Lobby", "Crystal Capture"], ["To Crystal Capture From Ocean", "Crystal Capture"], @@ -172,6 +191,7 @@ ["To Sand Capture From Ocean", "Sand Capture"], ["To Metal Capture From Bedroom", "Metal Capture"], ["To Metal Capture From Projector Room", "Metal Capture"], - ["To Metal Capture From Prehistoric", "Metal Capture"] + ["To Metal Capture From Prehistoric", "Metal Capture"], + ["To Victory", "Victory"] ] } diff --git a/worlds/shivers/docs/en_Shivers.md b/worlds/shivers/docs/en_Shivers.md index 2c56152a7a0c..9490b577bdd0 100644 --- a/worlds/shivers/docs/en_Shivers.md +++ b/worlds/shivers/docs/en_Shivers.md @@ -27,5 +27,4 @@ Victory is achieved when the player has captured the required number Ixupi set i ## Encountered a bug? -Please contact GodlFire on Discord for bugs related to Shivers world generation.
-Please contact GodlFire or mouse on Discord for bugs related to the Shivers Randomizer. +Please contact GodlFire or Cynbel_Terreus on Discord for bugs related to Shivers world generation or the Shivers Randomizer. From ca1b3df45b0c9939dffa9cada01dee3c23291911 Mon Sep 17 00:00:00 2001 From: Kory Dondzila Date: Fri, 27 Dec 2024 17:38:01 -0500 Subject: [PATCH 057/144] Shivers: Follow on PR to cleanup options #4401 --- worlds/shivers/Options.py | 80 +++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 46 deletions(-) diff --git a/worlds/shivers/Options.py b/worlds/shivers/Options.py index 2e68c4beecc0..5aa6c207cfc1 100644 --- a/worlds/shivers/Options.py +++ b/worlds/shivers/Options.py @@ -1,7 +1,8 @@ from dataclasses import dataclass from Options import ( - Choice, DefaultOnToggle, ItemDict, ItemSet, LocationSet, OptionGroup, PerGameCommonOptions, Range, Toggle, + Choice, DefaultOnToggle, ExcludeLocations, LocalItems, NonLocalItems, OptionGroup, PerGameCommonOptions, + PriorityLocations, Range, StartHints, StartInventory, StartLocationHints, Toggle, ) from . import ItemType, item_table from .Constants import location_info @@ -129,53 +130,38 @@ class PuzzleCollectBehavior(Choice): valid_location_keys = [name for name in location_info["all_locations"] if name != "Mystery Solved"] -class LocalItems(ItemSet): - """Forces these items to be in their native world.""" - display_name = "Local Items" - rich_text_doc = True +class ShiversLocalItems(LocalItems): + __doc__ = LocalItems.__doc__ valid_keys = valid_item_keys -class NonLocalItems(ItemSet): - """Forces these items to be outside their native world.""" - display_name = "Non-local Items" - rich_text_doc = True +class ShiversNonLocalItems(NonLocalItems): + __doc__ = NonLocalItems.__doc__ valid_keys = valid_item_keys -class StartInventory(ItemDict): - """Start with these items.""" - verify_item_name = True - display_name = "Start Inventory" - rich_text_doc = True +class ShiversStartInventory(StartInventory): + __doc__ = StartInventory.__doc__ valid_keys = valid_item_keys -class StartHints(ItemSet): - """Start with these item's locations prefilled into the ``!hint`` command.""" - display_name = "Start Hints" - rich_text_doc = True +class ShiversStartHints(StartHints): + __doc__ = StartHints.__doc__ valid_keys = valid_item_keys -class StartLocationHints(LocationSet): - """Start with these locations and their item prefilled into the ``!hint`` command.""" - display_name = "Start Location Hints" - rich_text_doc = True +class ShiversStartLocationHints(StartLocationHints): + __doc__ = StartLocationHints.__doc__ valid_keys = valid_location_keys -class ExcludeLocations(LocationSet): - """Prevent these locations from having an important item.""" - display_name = "Excluded Locations" - rich_text_doc = True +class ShiversExcludeLocations(ExcludeLocations): + __doc__ = ExcludeLocations.__doc__ valid_keys = valid_location_keys -class PriorityLocations(LocationSet): - """Prevent these locations from having an unimportant item.""" - display_name = "Priority Locations" - rich_text_doc = True +class ShiversPriorityLocations(PriorityLocations): + __doc__ = PriorityLocations.__doc__ valid_keys = valid_location_keys @@ -192,23 +178,25 @@ class ShiversOptions(PerGameCommonOptions): location_pot_pieces: LocationPotPieces full_pots: FullPots puzzle_collect_behavior: PuzzleCollectBehavior - local_items: LocalItems - non_local_items: NonLocalItems - start_inventory: StartInventory - start_hints: StartHints - start_location_hints: StartLocationHints - exclude_locations: ExcludeLocations - priority_locations: PriorityLocations + local_items: ShiversLocalItems + non_local_items: ShiversNonLocalItems + start_inventory: ShiversStartInventory + start_hints: ShiversStartHints + start_location_hints: ShiversStartLocationHints + exclude_locations: ShiversExcludeLocations + priority_locations: ShiversPriorityLocations shivers_option_groups = [ - OptionGroup("Item & Location Options", [ - LocalItems, - NonLocalItems, - StartInventory, - StartHints, - StartLocationHints, - ExcludeLocations, - PriorityLocations - ], True), + OptionGroup( + "Item & Location Options", [ + ShiversLocalItems, + ShiversNonLocalItems, + ShiversStartInventory, + ShiversStartHints, + ShiversStartLocationHints, + ShiversExcludeLocations, + ShiversPriorityLocations + ], True, + ), ] From 2065246186a56a345593232c928afff3e587e34b Mon Sep 17 00:00:00 2001 From: CaitSith2 Date: Sun, 29 Dec 2024 11:13:34 -0800 Subject: [PATCH 058/144] Factorio: Make it possible to use rocket part in blueprint parameterization. (#4396) This allows for example, making a blueprint of your rocket silo with requester chests specifying a request for the 2-8 rocket part ingredients needed to build the rocket. --- worlds/factorio/data/mod_template/data-final-fixes.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/factorio/data/mod_template/data-final-fixes.lua b/worlds/factorio/data/mod_template/data-final-fixes.lua index dc068c4f62aa..60a56068b788 100644 --- a/worlds/factorio/data/mod_template/data-final-fixes.lua +++ b/worlds/factorio/data/mod_template/data-final-fixes.lua @@ -1,6 +1,7 @@ {% from "macros.lua" import dict_to_recipe, variable_to_lua %} -- this file gets written automatically by the Archipelago Randomizer and is in its raw form a Jinja2 Template require('lib') +data.raw["item"]["rocket-part"].hidden = false data.raw["rocket-silo"]["rocket-silo"].fluid_boxes = { { production_type = "input", From fa95ae4b24fb2b954e9a9923d3672b2f45ce9fc4 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 29 Dec 2024 20:55:40 +0100 Subject: [PATCH 059/144] Factorio: require version that fixes a randomizer exploit (#4391) --- worlds/factorio/Mod.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/factorio/Mod.py b/worlds/factorio/Mod.py index 7dee04afbee3..8ea0b24c3d27 100644 --- a/worlds/factorio/Mod.py +++ b/worlds/factorio/Mod.py @@ -37,8 +37,8 @@ "description": "Integration client for the Archipelago Randomizer", "factorio_version": "2.0", "dependencies": [ - "base >= 2.0.15", - "? quality >= 2.0.15", + "base >= 2.0.28", + "? quality >= 2.0.28", "! space-age", "? science-not-invited", "? factory-levels" From 0de1369ec5dd8f37f8b31148f7c354804a3f8876 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 29 Dec 2024 20:56:41 +0100 Subject: [PATCH 060/144] Factorio: hide hidden vanilla techs in factoriopedia too (#4332) --- worlds/factorio/data/mod_template/data-final-fixes.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/factorio/data/mod_template/data-final-fixes.lua b/worlds/factorio/data/mod_template/data-final-fixes.lua index 60a56068b788..8092062bc3f2 100644 --- a/worlds/factorio/data/mod_template/data-final-fixes.lua +++ b/worlds/factorio/data/mod_template/data-final-fixes.lua @@ -163,6 +163,7 @@ data.raw["ammo"]["artillery-shell"].stack_size = 10 {# each randomized tech gets set to be invisible, with new nodes added that trigger those #} {%- for original_tech_name in base_tech_table -%} technologies["{{ original_tech_name }}"].hidden = true +technologies["{{ original_tech_name }}"].hidden_in_factoriopedia = true {% endfor %} {%- for location, item in locations %} {#- the tech researched by the local player #} From 8dbecf3d57fe4dbcabe7bb3068104d074193b7f7 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Mon, 30 Dec 2024 00:50:39 +0100 Subject: [PATCH 061/144] The Witness: Make location order in the spoiler log deterministic (#3895) * Fix location order * Update worlds/witness/data/static_logic.py Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- worlds/witness/data/static_logic.py | 2 ++ worlds/witness/regions.py | 29 +++++++++++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/worlds/witness/data/static_logic.py b/worlds/witness/data/static_logic.py index 58f2e894e849..6cc4e1431d07 100644 --- a/worlds/witness/data/static_logic.py +++ b/worlds/witness/data/static_logic.py @@ -106,6 +106,7 @@ def read_logic_file(self, lines: List[str]) -> None: "entityType": location_id, "locationType": None, "area": current_area, + "order": len(self.ENTITIES_BY_HEX), } self.ENTITIES_BY_NAME[self.ENTITIES_BY_HEX[entity_hex]["checkName"]] = self.ENTITIES_BY_HEX[entity_hex] @@ -186,6 +187,7 @@ def read_logic_file(self, lines: List[str]) -> None: "entityType": entity_type, "locationType": location_type, "area": current_area, + "order": len(self.ENTITIES_BY_HEX), } self.ENTITY_ID_TO_NAME[entity_hex] = full_entity_name diff --git a/worlds/witness/regions.py b/worlds/witness/regions.py index 1df438f68b0d..a1f7df8a310c 100644 --- a/worlds/witness/regions.py +++ b/worlds/witness/regions.py @@ -114,7 +114,7 @@ def create_regions(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic if k not in player_logic.UNREACHABLE_REGIONS } - event_locations_per_region = defaultdict(list) + event_locations_per_region = defaultdict(dict) for event_location, event_item_and_entity in player_logic.EVENT_ITEM_PAIRS.items(): region = static_witness_logic.ENTITIES_BY_HEX[event_item_and_entity[1]]["region"] @@ -122,20 +122,33 @@ def create_regions(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic region_name = "Entry" else: region_name = region["name"] - event_locations_per_region[region_name].append(event_location) + order = self.reference_logic.ENTITIES_BY_HEX[event_item_and_entity[1]]["order"] + event_locations_per_region[region_name][event_location] = order for region_name, region in regions_to_create.items(): - locations_for_this_region = [ - self.reference_logic.ENTITIES_BY_HEX[panel]["checkName"] for panel in region["entities"] - if self.reference_logic.ENTITIES_BY_HEX[panel]["checkName"] - in self.player_locations.CHECK_LOCATION_TABLE + location_entities_for_this_region = [ + self.reference_logic.ENTITIES_BY_HEX[entity] for entity in region["entities"] ] + locations_for_this_region = { + entity["checkName"]: entity["order"] for entity in location_entities_for_this_region + if entity["checkName"] in self.player_locations.CHECK_LOCATION_TABLE + } - locations_for_this_region += event_locations_per_region[region_name] + events = event_locations_per_region[region_name] + locations_for_this_region.update(events) + + # First, sort by keys. + locations_for_this_region = dict(sorted(locations_for_this_region.items())) + + # Then, sort by game order (values) + locations_for_this_region = dict(sorted( + locations_for_this_region.items(), + key=lambda location_name_and_order: location_name_and_order[1] + )) all_locations = all_locations | set(locations_for_this_region) - new_region = create_region(world, region_name, self.player_locations, locations_for_this_region) + new_region = create_region(world, region_name, self.player_locations, list(locations_for_this_region)) regions_by_name[region_name] = new_region From c4bbcf989036ffe698fe11179defcbd107dc55e8 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Tue, 31 Dec 2024 04:57:09 +0000 Subject: [PATCH 062/144] TUNIC: Add relics and abilities to the item pool in deterministic order (#4411) --- worlds/tunic/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index 29dbf150125c..8525a3fc437d 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -284,12 +284,14 @@ def remove_filler(amount: int) -> None: remove_filler(items_to_create[gold_hexagon]) - for hero_relic in item_name_groups["Hero Relics"]: + # Sort for deterministic order + for hero_relic in sorted(item_name_groups["Hero Relics"]): tunic_items.append(self.create_item(hero_relic, ItemClassification.useful)) items_to_create[hero_relic] = 0 if not self.options.ability_shuffling: - for page in item_name_groups["Abilities"]: + # Sort for deterministic order + for page in sorted(item_name_groups["Abilities"]): if items_to_create[page] > 0: tunic_items.append(self.create_item(page, ItemClassification.useful)) items_to_create[page] = 0 From 3c9270d8029ac5445d6055cac5c9a464b3a33ba8 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Tue, 31 Dec 2024 14:02:02 +0000 Subject: [PATCH 063/144] FFMQ: Create itempool in deterministic order (#4413) --- worlds/ffmq/Items.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/ffmq/Items.py b/worlds/ffmq/Items.py index f1c102d34ef8..31453a0fef29 100644 --- a/worlds/ffmq/Items.py +++ b/worlds/ffmq/Items.py @@ -260,7 +260,8 @@ def add_item(item_name): items.append(i) for item_group in ("Key Items", "Spells", "Armors", "Helms", "Shields", "Accessories", "Weapons"): - for item in self.item_name_groups[item_group]: + # Sort for deterministic order + for item in sorted(self.item_name_groups[item_group]): add_item(item) if self.options.brown_boxes == "include": From 6e59ee2926410ed791cbcd6413ffa0b158974a94 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Tue, 31 Dec 2024 14:16:29 +0000 Subject: [PATCH 064/144] Zork Grand Inquisitor: Precollect Start with Hotspot Items in deterministic order (#4412) --- worlds/zork_grand_inquisitor/world.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/zork_grand_inquisitor/world.py b/worlds/zork_grand_inquisitor/world.py index a93f2c2134c1..3698ad7f8960 100644 --- a/worlds/zork_grand_inquisitor/world.py +++ b/worlds/zork_grand_inquisitor/world.py @@ -176,7 +176,7 @@ def create_items(self) -> None: if start_with_hotspot_items: item: ZorkGrandInquisitorItems - for item in items_with_tag(ZorkGrandInquisitorTags.HOTSPOT): + for item in sorted(items_with_tag(ZorkGrandInquisitorTags.HOTSPOT), key=lambda item: item.name): self.multiworld.push_precollected(self.create_item(item.value)) def create_item(self, name: str) -> ZorkGrandInquisitorItem: From 917335ec54210c4b368cb3ba7b202e133a1c12c9 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Wed, 1 Jan 2025 02:02:18 +0100 Subject: [PATCH 065/144] Core: it's 2025 (#4417) --- LICENSE | 2 +- WebHostLib/templates/islandFooter.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 40716cff4275..60d31b7b7de8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License Copyright (c) 2017 LLCoolDave -Copyright (c) 2022 Berserker66 +Copyright (c) 2025 Berserker66 Copyright (c) 2022 CaitSith2 Copyright (c) 2021 LegendaryLinux diff --git a/WebHostLib/templates/islandFooter.html b/WebHostLib/templates/islandFooter.html index 08cf227990b8..7de14f0d827c 100644 --- a/WebHostLib/templates/islandFooter.html +++ b/WebHostLib/templates/islandFooter.html @@ -1,6 +1,6 @@ {% block footer %}