From 539307cf0b3f1fa00b178d09ceee438a8a286890 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Thu, 15 Feb 2024 23:03:51 -0500 Subject: [PATCH] TUNIC: Universal Tracker Support Update (#2786) Adds better support for the Universal Tracker (see its channel in the future game design section). This does absolutely nothing regarding standard gen, just adds some checks for an attribute that only exists when UT is being used. --- worlds/tunic/__init__.py | 72 +++++++++----------- worlds/tunic/er_data.py | 2 +- worlds/tunic/er_scripts.py | 136 ++++++++++++++++++++++++++++++++++--- 3 files changed, 159 insertions(+), 51 deletions(-) diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index d8311de8..7dfb5c09 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -59,6 +59,20 @@ class TunicWorld(World): er_portal_hints: Dict[int, str] def generate_early(self) -> None: + # Universal tracker stuff, shouldn't do anything in standard gen + if hasattr(self.multiworld, "re_gen_passthrough"): + if "TUNIC" in self.multiworld.re_gen_passthrough: + passthrough = self.multiworld.re_gen_passthrough["TUNIC"] + self.options.start_with_sword.value = passthrough["start_with_sword"] + self.options.keys_behind_bosses.value = passthrough["keys_behind_bosses"] + self.options.sword_progression.value = passthrough["sword_progression"] + self.options.ability_shuffling.value = passthrough["ability_shuffling"] + self.options.logic_rules.value = passthrough["logic_rules"] + self.options.lanternless.value = passthrough["lanternless"] + self.options.maskless.value = passthrough["maskless"] + self.options.hexagon_quest.value = passthrough["hexagon_quest"] + self.options.entrance_rando.value = passthrough["entrance_rando"] + if self.options.start_with_sword and "Sword" not in self.options.start_inventory: self.options.start_inventory.value["Sword"] = 1 @@ -150,10 +164,20 @@ class TunicWorld(World): self.tunic_portal_pairs = {} self.er_portal_hints = {} self.ability_unlocks = randomize_ability_unlocks(self.random, self.options) + + # stuff for universal tracker support, can be ignored for standard gen + if hasattr(self.multiworld, "re_gen_passthrough"): + if "TUNIC" in self.multiworld.re_gen_passthrough: + passthrough = self.multiworld.re_gen_passthrough["TUNIC"] + self.ability_unlocks["Pages 24-25 (Prayer)"] = passthrough["Hexagon Quest Prayer"] + self.ability_unlocks["Pages 42-43 (Holy Cross)"] = passthrough["Hexagon Quest Holy Cross"] + self.ability_unlocks["Pages 52-53 (Ice Rod)"] = passthrough["Hexagon Quest Ice Rod"] + if self.options.entrance_rando: portal_pairs, portal_hints = create_er_regions(self) for portal1, portal2 in portal_pairs.items(): self.tunic_portal_pairs[portal1.scene_destination()] = portal2.scene_destination() + self.er_portal_hints = portal_hints else: @@ -199,6 +223,9 @@ class TunicWorld(World): "ability_shuffling": self.options.ability_shuffling.value, "hexagon_quest": self.options.hexagon_quest.value, "fool_traps": self.options.fool_traps.value, + "logic_rules": self.options.logic_rules.value, + "lanternless": self.options.lanternless.value, + "maskless": self.options.maskless.value, "entrance_rando": self.options.entrance_rando.value, "Hexagon Quest Prayer": self.ability_unlocks["Pages 24-25 (Prayer)"], "Hexagon Quest Holy Cross": self.ability_unlocks["Pages 42-43 (Holy Cross)"], @@ -236,44 +263,7 @@ class TunicWorld(World): return slot_data # for the universal tracker, doesn't get called in standard gen - def interpret_slot_data(self, slot_data: Dict[str, Any]) -> None: - # bypassing random yaml settings - self.options.start_with_sword.value = slot_data["start_with_sword"] - self.options.keys_behind_bosses.value = slot_data["keys_behind_bosses"] - self.options.sword_progression.value = slot_data["sword_progression"] - self.options.ability_shuffling.value = slot_data["ability_shuffling"] - self.options.hexagon_quest.value = slot_data["hexagon_quest"] - self.ability_unlocks["Pages 24-25 (Prayer)"] = slot_data["Hexagon Quest Prayer"] - self.ability_unlocks["Pages 42-43 (Holy Cross)"] = slot_data["Hexagon Quest Holy Cross"] - self.ability_unlocks["Pages 52-53 (Ice Rod)"] = slot_data["Hexagon Quest Ice Rod"] - - # swapping entrances around so the mapping matches what was generated - if slot_data["entrance_rando"]: - from BaseClasses import Entrance - from .er_data import portal_mapping - entrance_dict: Dict[str, Entrance] = {entrance.name: entrance - for region in self.multiworld.get_regions(self.player) - for entrance in region.entrances} - slot_portals: Dict[str, str] = slot_data["Entrance Rando"] - for portal1, portal2 in slot_portals.items(): - portal_name1: str = "" - portal_name2: str = "" - entrance1 = None - entrance2 = None - for portal in portal_mapping: - if portal.scene_destination() == portal1: - portal_name1 = portal.name - if portal.scene_destination() == portal2: - portal_name2 = portal.name - - for entrance_name, entrance in entrance_dict.items(): - if entrance_name.startswith(portal_name1): - entrance1 = entrance - if entrance_name.startswith(portal_name2): - entrance2 = entrance - if entrance1 is None: - raise Exception("entrance1 not found, portal1 is " + portal1) - if entrance2 is None: - raise Exception("entrance2 not found, portal2 is " + portal2) - entrance1.connected_region = entrance2.parent_region - entrance2.connected_region = entrance1.parent_region + @staticmethod + def interpret_slot_data(slot_data: Dict[str, Any]) -> Dict[str, Any]: + # returning slot_data so it regens, giving it back in multiworld.re_gen_passthrough + return slot_data diff --git a/worlds/tunic/er_data.py b/worlds/tunic/er_data.py index 95d33d4a..d76af113 100644 --- a/worlds/tunic/er_data.py +++ b/worlds/tunic/er_data.py @@ -730,7 +730,7 @@ for p1, p2 in hallways_ur.items(): # the key is the region you have, the value is the regions you get for having that region # this is mostly so we don't have to do something overly complex to get this information -dependent_regions: Dict[Tuple[str, ...], List[str]] = { +dependent_regions_restricted: Dict[Tuple[str, ...], List[str]] = { ("Overworld", "Overworld Belltower", "Overworld Swamp Upper Entry", "Overworld Special Shop Entry", "Overworld West Garden Laurels Entry", "Overworld Southeast Cross Door", "Overworld Temple Door", "Overworld Fountain Cross Door", "Overworld Town Portal", "Overworld Spawn Portal"): diff --git a/worlds/tunic/er_scripts.py b/worlds/tunic/er_scripts.py index 4e28344b..d2b854f5 100644 --- a/worlds/tunic/er_scripts.py +++ b/worlds/tunic/er_scripts.py @@ -2,8 +2,9 @@ from typing import Dict, List, Set, Tuple, TYPE_CHECKING from BaseClasses import Region, ItemClassification, Item, Location from .locations import location_table from .er_data import Portal, tunic_er_regions, portal_mapping, hallway_helper, hallway_helper_ur, \ - dependent_regions, dependent_regions_nmg, dependent_regions_ur + dependent_regions_restricted, dependent_regions_nmg, dependent_regions_ur from .er_rules import set_er_region_rules +from worlds.generic import PlandoConnection if TYPE_CHECKING: from . import TunicWorld @@ -22,12 +23,17 @@ def create_er_regions(world: "TunicWorld") -> Tuple[Dict[Portal, Portal], Dict[i portal_pairs: Dict[Portal, Portal] = pair_portals(world) logic_rules = world.options.logic_rules + # output the entrances to the spoiler log here for convenience + for portal1, portal2 in portal_pairs.items(): + world.multiworld.spoiler.set_entrance(portal1.name, portal2.name, "both", world.player) + # check if a portal leads to a hallway. if it does, update the hint text accordingly def hint_helper(portal: Portal, hint_string: str = "") -> str: # start by setting it as the name of the portal, for the case we're not using the hallway helper if hint_string == "": hint_string = portal.name + # unrestricted has fewer hallways, like the well rail if logic_rules == "unrestricted": hallways = hallway_helper_ur else: @@ -69,6 +75,7 @@ def create_er_regions(world: "TunicWorld") -> Tuple[Dict[Portal, Portal], Dict[i return hint_string # create our regions, give them hint text if they're in a spot where it makes sense to + # we're limiting which ones get hints so that it still gets that ER feel with a little less BS for region_name, region_data in tunic_er_regions.items(): hint_text = "error" if region_data.hint == 1: @@ -90,7 +97,7 @@ def create_er_regions(world: "TunicWorld") -> Tuple[Dict[Portal, Portal], Dict[i break regions[region_name] = Region(region_name, world.player, world.multiworld, hint_text) elif region_data.hint == 3: - # only the west garden portal item for now + # west garden portal item is at a dead end in restricted, otherwise just in west garden if region_name == "West Garden Portal Item": if world.options.logic_rules: for portal1, portal2 in portal_pairs.items(): @@ -178,9 +185,17 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: portal_pairs: Dict[Portal, Portal] = {} dead_ends: List[Portal] = [] two_plus: List[Portal] = [] + plando_connections: List[PlandoConnection] = [] fixed_shop = False logic_rules = world.options.logic_rules.value + if not logic_rules: + dependent_regions = dependent_regions_restricted + elif logic_rules == 1: + dependent_regions = dependent_regions_nmg + else: + dependent_regions = dependent_regions_ur + # create separate lists for dead ends and non-dead ends if logic_rules: for portal in portal_mapping: @@ -200,8 +215,46 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: start_region = "Overworld" connected_regions.update(add_dependent_regions(start_region, logic_rules)) + # universal tracker support stuff, don't need to care about region dependency + if hasattr(world.multiworld, "re_gen_passthrough"): + if "TUNIC" in world.multiworld.re_gen_passthrough: + # universal tracker stuff, won't do anything in normal gen + for portal1, portal2 in world.multiworld.re_gen_passthrough["TUNIC"]["Entrance Rando"].items(): + portal_name1 = "" + portal_name2 = "" + + # skip this if 10 fairies laurels location is on, it can be handled normally + if portal1 == "Overworld Redux, Waterfall_" and portal2 == "Waterfall, Overworld Redux_" \ + and world.options.laurels_location == "10_fairies": + continue + + for portal in portal_mapping: + if portal.scene_destination() == portal1: + portal_name1 = portal.name + # connected_regions.update(add_dependent_regions(portal.region, logic_rules)) + if portal.scene_destination() == portal2: + portal_name2 = portal.name + # connected_regions.update(add_dependent_regions(portal.region, logic_rules)) + # shops have special handling + if not portal_name2 and portal2 == "Shop, Previous Region_": + portal_name2 = "Shop Portal" + plando_connections.append(PlandoConnection(portal_name1, portal_name2, "both")) + + if plando_connections: + portal_pairs, dependent_regions, dead_ends, two_plus = \ + create_plando_connections(plando_connections, dependent_regions, dead_ends, two_plus) + + # if we have plando connections, our connected regions may change somewhat + while True: + test1 = len(connected_regions) + for region in connected_regions.copy(): + connected_regions.update(add_dependent_regions(region, logic_rules)) + test2 = len(connected_regions) + if test1 == test2: + break + # need to plando fairy cave, or it could end up laurels locked - # fix this later to be random? probably not? + # fix this later to be random after adding some item logic to dependent regions if world.options.laurels_location == "10_fairies": portal1 = None portal2 = None @@ -217,7 +270,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: two_plus.remove(portal1) dead_ends.remove(portal2) - if world.options.fixed_shop: + if world.options.fixed_shop and not hasattr(world.multiworld, "re_gen_passthrough"): fixed_shop = True portal1 = None for portal in two_plus: @@ -283,6 +336,11 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: shop_count = 1 shop_scenes.add("Overworld Redux") + # for universal tracker, we want to skip shop gen + if hasattr(world.multiworld, "re_gen_passthrough"): + if "TUNIC" in world.multiworld.re_gen_passthrough: + shop_count = 0 + for i in range(shop_count): portal1 = None for portal in two_plus: @@ -311,10 +369,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: portal_pairs[portal1] = portal2 if len(two_plus) == 1: - raise Exception("two plus had an odd number of portals, investigate this") - - for portal1, portal2 in portal_pairs.items(): - world.multiworld.spoiler.set_entrance(portal1.name, portal2.name, "both", world.player) + raise Exception("two plus had an odd number of portals, investigate this. last portal is " + two_plus[0].name) return portal_pairs @@ -331,10 +386,11 @@ def create_randomized_entrances(portal_pairs: Dict[Portal, Portal], regions: Dic # loop through the static connections, return regions you can reach from this region +# todo: refactor to take region_name and dependent_regions def add_dependent_regions(region_name: str, logic_rules: int) -> Set[str]: region_set = set() if not logic_rules: - regions_to_add = dependent_regions + regions_to_add = dependent_regions_restricted elif logic_rules == 1: regions_to_add = dependent_regions_nmg else: @@ -451,3 +507,65 @@ def gate_before_switch(check_portal: Portal, two_plus: List[Portal]) -> bool: # false means you're good to place the portal return False + + +# this is for making the connections themselves +def create_plando_connections(plando_connections: List[PlandoConnection], + dependent_regions: Dict[Tuple[str, ...], List[str]], dead_ends: List[Portal], + two_plus: List[Portal]) \ + -> Tuple[Dict[Portal, Portal], Dict[Tuple[str, ...], List[str]], List[Portal], List[Portal]]: + + portal_pairs: Dict[Portal, Portal] = {} + shop_num = 1 + for connection in plando_connections: + p_entrance = connection.entrance + p_exit = connection.exit + + portal1 = None + portal2 = None + + # search two_plus for both at once + for portal in two_plus: + if p_entrance == portal.name: + portal1 = portal + if p_exit == portal.name: + portal2 = portal + + # search dead_ends individually since we can't really remove items from two_plus during the loop + if not portal1: + for portal in dead_ends: + if p_entrance == portal.name: + portal1 = portal + break + dead_ends.remove(portal1) + else: + two_plus.remove(portal1) + + if not portal2: + for portal in dead_ends: + if p_exit == portal.name: + portal2 = portal + break + if p_exit == "Shop Portal": + portal2 = Portal(name="Shop Portal", region=f"Shop Entrance {shop_num}", destination="Previous Region_") + shop_num += 1 + else: + dead_ends.remove(portal2) + else: + two_plus.remove(portal2) + + if not portal1: + raise Exception("could not find entrance named " + p_entrance + " for Tunic player's plando") + if not portal2: + raise Exception("could not find entrance named " + p_exit + " for Tunic player's plando") + + portal_pairs[portal1] = portal2 + + # update dependent regions based on the plando'd connections, to make sure the portals connect well, logically + for origins, destinations in dependent_regions.items(): + if portal1.region in origins: + destinations.append(portal2.region) + if portal2.region in origins: + destinations.append(portal1.region) + + return portal_pairs, dependent_regions, dead_ends, two_plus