diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index b10ccd43af59..c4b1bbec8ea3 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -226,7 +226,7 @@ def fill_slot_data(self) -> Dict[str, Any]: "logic_rules": self.options.logic_rules.value, "lanternless": self.options.lanternless.value, "maskless": self.options.maskless.value, - "entrance_rando": self.options.entrance_rando.value, + "entrance_rando": bool(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)"], "Hexagon Quest Icebolt": self.ability_unlocks["Pages 52-53 (Icebolt)"], diff --git a/worlds/tunic/docs/en_TUNIC.md b/worlds/tunic/docs/en_TUNIC.md index 1204f2ef4ca2..5921d0ed092d 100644 --- a/worlds/tunic/docs/en_TUNIC.md +++ b/worlds/tunic/docs/en_TUNIC.md @@ -67,4 +67,22 @@ For the Entrance Randomizer: Bombs, consumables (non-bomb ones), weapons, melee weapons (stick and sword), keys, hexagons, offerings, hero relics, cards, golden treasures, money, pages, and abilities (the three ability pages). There are also a few groups being used for singular items: laurels, orb, dagger, magic rod, holy cross, prayer, icebolt, and progressive sword. ## What location groups are there? -Holy cross (for all holy cross checks), fairies (for the two fairy checks), well (for the coin well checks), and shop. Additionally, for checks that do not fall into the above categories, the name of the region is the name of the location group. \ No newline at end of file +Holy cross (for all holy cross checks), fairies (for the two fairy checks), well (for the coin well checks), and shop. Additionally, for checks that do not fall into the above categories, the name of the region is the name of the location group. + +## Is Connection Plando supported? +Yes. The host needs to enable it in their `host.yaml`, and the player's yaml needs to contain a plando_connections block. +Example: +``` +plando_connections: + - entrance: Stick House Entrance + exit: Stick House Exit + - entrance: Special Shop Exit + exit: Stairs to Top of the Mountain +``` +Notes: +- The Entrance Randomizer option must be enabled for it to work. +- The `direction` field is not supported. Connections are always coupled. +- For a list of entrance names, check `er_data.py` in the TUNIC world folder or generate a game with the Entrance Randomizer option enabled and check the spoiler log. +- There is no limit to the number of Shops hard-coded into place. +- If you have more than one shop in a scene, you may be wrong warped when exiting a shop. +- If you have a shop in every scene, and you have an odd number of shops, it will error out. diff --git a/worlds/tunic/er_data.py b/worlds/tunic/er_data.py index 7678d77fe034..8d8db426f67d 100644 --- a/worlds/tunic/er_data.py +++ b/worlds/tunic/er_data.py @@ -682,12 +682,6 @@ class Hint(IntEnum): "Hero Relic - Library": RegionInfo("RelicVoid", dead_end=DeadEnd.all_cats, hint=Hint.region), "Hero Relic - Swamp": RegionInfo("RelicVoid", dead_end=DeadEnd.all_cats, hint=Hint.region), "Purgatory": RegionInfo("Purgatory"), - "Shop Entrance 1": RegionInfo("Shop", dead_end=DeadEnd.all_cats), - "Shop Entrance 2": RegionInfo("Shop", dead_end=DeadEnd.all_cats), - "Shop Entrance 3": RegionInfo("Shop", dead_end=DeadEnd.all_cats), - "Shop Entrance 4": RegionInfo("Shop", dead_end=DeadEnd.all_cats), - "Shop Entrance 5": RegionInfo("Shop", dead_end=DeadEnd.all_cats), - "Shop Entrance 6": RegionInfo("Shop", dead_end=DeadEnd.all_cats), "Shop": RegionInfo("Shop", dead_end=DeadEnd.all_cats), "Spirit Arena": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats, hint=Hint.region), "Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats) diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index a7d0543c3f17..fec6635422ac 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -619,19 +619,6 @@ def set_er_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int], re connecting_region=regions["Far Shore"]) # Misc - regions["Shop Entrance 1"].connect( - connecting_region=regions["Shop"]) - regions["Shop Entrance 2"].connect( - connecting_region=regions["Shop"]) - regions["Shop Entrance 3"].connect( - connecting_region=regions["Shop"]) - regions["Shop Entrance 4"].connect( - connecting_region=regions["Shop"]) - regions["Shop Entrance 5"].connect( - connecting_region=regions["Shop"]) - regions["Shop Entrance 6"].connect( - connecting_region=regions["Shop"]) - regions["Spirit Arena"].connect( connecting_region=regions["Spirit Arena Victory"], rule=lambda state: (state.has(gold_hexagon, player, world.options.hexagon_goal.value) if diff --git a/worlds/tunic/er_scripts.py b/worlds/tunic/er_scripts.py index d2b854f5df0e..291cd7b3310e 100644 --- a/worlds/tunic/er_scripts.py +++ b/worlds/tunic/er_scripts.py @@ -5,6 +5,7 @@ dependent_regions_restricted, dependent_regions_nmg, dependent_regions_ur from .er_rules import set_er_region_rules from worlds.generic import PlandoConnection +from random import Random if TYPE_CHECKING: from . import TunicWorld @@ -185,9 +186,14 @@ 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 + player_name = world.multiworld.get_player_name(world.player) + + shop_scenes: Set[str] = set() + shop_count = 6 + if world.options.fixed_shop.value: + shop_count = 1 + shop_scenes.add("Overworld Redux") if not logic_rules: dependent_regions = dependent_regions_restricted @@ -215,19 +221,17 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: start_region = "Overworld" connected_regions.update(add_dependent_regions(start_region, logic_rules)) + plando_connections = world.multiworld.plando_connections[world.player] + # 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: + plando_connections.clear() # 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 @@ -240,9 +244,78 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: portal_name2 = "Shop Portal" plando_connections.append(PlandoConnection(portal_name1, portal_name2, "both")) + non_dead_end_regions = set() + for region_name, region_info in tunic_er_regions.items(): + if not region_info.dead_end: + non_dead_end_regions.add(region_name) + elif region_info.dead_end == 2 and logic_rules: + non_dead_end_regions.add(region_name) + if plando_connections: - portal_pairs, dependent_regions, dead_ends, two_plus = \ - create_plando_connections(plando_connections, dependent_regions, dead_ends, two_plus) + for connection in plando_connections: + p_entrance = connection.entrance + p_exit = connection.exit + + if p_entrance.startswith("Shop"): + p_entrance = p_exit + p_exit = "Shop Portal" + + 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 + if not portal1: + raise Exception(f"Could not find entrance named {p_entrance} for " + f"plando connections in {player_name}'s YAML.") + 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 in ["Shop Portal", "Shop"]: + portal2 = Portal(name="Shop Portal", region=f"Shop", + destination="Previous Region_") + shop_count -= 1 + if shop_count < 0: + shop_count += 2 + for p in portal_mapping: + if p.name == p_entrance: + shop_scenes.add(p.scene()) + break + else: + if not portal2: + raise Exception(f"Could not find entrance named {p_exit} for " + f"plando connections in {player_name}'s YAML.") + dead_ends.remove(portal2) + else: + two_plus.remove(portal2) + + portal_pairs[portal1] = portal2 + + # update dependent regions based on the plando'd connections, to ensure the portals connect well, logically + for origins, destinations in dependent_regions.items(): + if portal1.region in origins: + if portal2.region in non_dead_end_regions: + destinations.append(portal2.region) + if portal2.region in origins: + if portal1.region in non_dead_end_regions: + destinations.append(portal1.region) # if we have plando connections, our connected regions may change somewhat while True: @@ -255,7 +328,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: # need to plando fairy cave, or it could end up laurels locked # fix this later to be random after adding some item logic to dependent regions - if world.options.laurels_location == "10_fairies": + if world.options.laurels_location == "10_fairies" and not hasattr(world.multiworld, "re_gen_passthrough"): portal1 = None portal2 = None for portal in two_plus: @@ -266,41 +339,59 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: if portal.scene_destination() == "Waterfall, Overworld Redux_": portal2 = portal break + if not portal1: + raise Exception(f"Failed to do Laurels Location at 10 Fairies option. " + f"Did {player_name} plando connection the Secret Gathering Place Entrance?") + if not portal2: + raise Exception(f"Failed to do Laurels Location at 10 Fairies option. " + f"Did {player_name} plando connection the Secret Gathering Place Exit?") portal_pairs[portal1] = portal2 two_plus.remove(portal1) dead_ends.remove(portal2) if world.options.fixed_shop and not hasattr(world.multiworld, "re_gen_passthrough"): - fixed_shop = True portal1 = None for portal in two_plus: if portal.scene_destination() == "Overworld Redux, Windmill_": portal1 = portal break - portal2 = Portal(name="Shop Portal", region=f"Shop Entrance 2", destination="Previous Region_") + portal2 = Portal(name="Shop Portal", region="Shop", destination="Previous Region_") + if not portal1: + raise Exception(f"Failed to do Fixed Shop option. " + f"Did {player_name} plando connection the Windmill Shop entrance?") portal_pairs[portal1] = portal2 two_plus.remove(portal1) + random_object: Random = world.random + if world.options.entrance_rando.value != 1: + random_object = Random(world.options.entrance_rando.value) # we want to start by making sure every region is accessible - non_dead_end_regions = set() - for region_name, region_info in tunic_er_regions.items(): - if not region_info.dead_end: - non_dead_end_regions.add(region_name) - elif region_info.dead_end == 2 and logic_rules: - non_dead_end_regions.add(region_name) - - world.random.shuffle(two_plus) + random_object.shuffle(two_plus) check_success = 0 portal1 = None portal2 = None + previous_conn_num = 0 + fail_count = 0 while len(connected_regions) < len(non_dead_end_regions): + # if the connected regions length stays unchanged for too long, it's stuck in a loop + # should, hopefully, only ever occur if someone plandos connections poorly + if hasattr(world.multiworld, "re_gen_passthrough"): + break + if previous_conn_num == len(connected_regions): + fail_count += 1 + if fail_count >= 500: + raise Exception(f"Failed to pair regions. Check plando connections for {player_name} for loops.") + else: + fail_count = 0 + previous_conn_num = len(connected_regions) + # find a portal in an inaccessible region if check_success == 0: for portal in two_plus: if portal.region in connected_regions: # if there's risk of self-locking, start over if gate_before_switch(portal, two_plus): - world.random.shuffle(two_plus) + random_object.shuffle(two_plus) break portal1 = portal two_plus.remove(portal) @@ -313,7 +404,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: if portal.region not in connected_regions: # if there's risk of self-locking, shuffle and try again if gate_before_switch(portal, two_plus): - world.random.shuffle(two_plus) + random_object.shuffle(two_plus) break portal2 = portal two_plus.remove(portal) @@ -325,16 +416,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: connected_regions.update(add_dependent_regions(portal2.region, logic_rules)) portal_pairs[portal1] = portal2 check_success = 0 - world.random.shuffle(two_plus) - - # add 6 shops, connect them to unique scenes - # this is due to a limitation in Tunic -- you wrong warp if there's multiple shops - shop_scenes: Set[str] = set() - shop_count = 6 - - if fixed_shop: - shop_count = 1 - shop_scenes.add("Overworld Redux") + random_object.shuffle(two_plus) # for universal tracker, we want to skip shop gen if hasattr(world.multiworld, "re_gen_passthrough"): @@ -350,13 +432,15 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: two_plus.remove(portal) break if portal1 is None: - raise Exception("Too many shops in the pool, or something else went wrong") - portal2 = Portal(name="Shop Portal", region=f"Shop Entrance {i + 1}", destination="Previous Region_") + raise Exception("Too many shops in the pool, or something else went wrong.") + portal2 = Portal(name="Shop Portal", region="Shop", destination="Previous Region_") portal_pairs[portal1] = portal2 # connect dead ends to random non-dead ends # none of the key events are in dead ends, so we don't need to do gate_before_switch while len(dead_ends) > 0: + if hasattr(world.multiworld, "re_gen_passthrough"): + break portal1 = two_plus.pop() portal2 = dead_ends.pop() portal_pairs[portal1] = portal2 @@ -364,6 +448,8 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: # then randomly connect the remaining portals to each other # every region is accessible, so gate_before_switch is not necessary while len(two_plus) > 1: + if hasattr(world.multiworld, "re_gen_passthrough"): + break portal1 = two_plus.pop() portal2 = two_plus.pop() portal_pairs[portal1] = portal2 @@ -381,7 +467,7 @@ def create_randomized_entrances(portal_pairs: Dict[Portal, Portal], regions: Dic region2 = regions[portal2.region] region1.connect(region2, f"{portal1.name} -> {portal2.name}") # prevent the logic from thinking you can get to any shop-connected region from the shop - if portal2.name != "Shop": + if not portal2.name.startswith("Shop"): region2.connect(region1, f"{portal2.name} -> {portal1.name}") @@ -507,65 +593,3 @@ 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 diff --git a/worlds/tunic/options.py b/worlds/tunic/options.py index ee42b1cfc480..779e632326db 100644 --- a/worlds/tunic/options.py +++ b/worlds/tunic/options.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from Options import DefaultOnToggle, Toggle, StartInventoryPool, Choice, Range, PerGameCommonOptions +from Options import DefaultOnToggle, Toggle, StartInventoryPool, Choice, Range, TextChoice, PerGameCommonOptions class SwordProgression(DefaultOnToggle): @@ -104,11 +104,17 @@ class ExtraHexagonPercentage(Range): default = 50 -class EntranceRando(Toggle): +class EntranceRando(TextChoice): """Randomize the connections between scenes. + You can choose a custom seed by editing this option. A small, very lost fox on a big adventure.""" internal_name = "entrance_rando" display_name = "Entrance Rando" + alias_false = 0 + option_no = 0 + alias_true = 1 + option_yes = 1 + default = 0 class FixedShop(Toggle): diff --git a/worlds/tunic/test/test_access.py b/worlds/tunic/test/test_access.py index d74858bd27ef..1c4f06d50461 100644 --- a/worlds/tunic/test/test_access.py +++ b/worlds/tunic/test/test_access.py @@ -59,7 +59,7 @@ def test_normal_goal(self): class TestER(TunicTestBase): - options = {options.EntranceRando.internal_name: options.EntranceRando.option_true, + 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}