diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index 356af56ebd3e..20fbd82df2c3 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -1,6 +1,6 @@ -from typing import Dict, List, Any +from typing import Dict, List, Any, Tuple, TypedDict from logging import warning -from BaseClasses import Region, Location, Item, Tutorial, ItemClassification +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 .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 @@ -8,8 +8,9 @@ from .regions import tunic_regions from .er_scripts import create_er_regions from .er_data import portal_mapping -from .options import TunicOptions +from .options import TunicOptions, EntranceRando from worlds.AutoWorld import WebWorld, World +from worlds.generic import PlandoConnection from decimal import Decimal, ROUND_HALF_UP @@ -36,6 +37,13 @@ class TunicLocation(Location): game: str = "TUNIC" +class SeedGroup(TypedDict): + logic_rules: int # logic rules value + laurels_at_10_fairies: bool # laurels location value + fixed_shop: bool # fixed shop value + plando: List[PlandoConnection] # consolidated list of plando connections for the seed group + + class TunicWorld(World): """ Explore a land filled with lost legends, ancient powers, and ferocious monsters in TUNIC, an isometric action game @@ -57,8 +65,21 @@ class TunicWorld(World): slot_data_items: List[TunicItem] tunic_portal_pairs: Dict[str, str] er_portal_hints: Dict[int, str] + seed_groups: Dict[str, SeedGroup] = {} def generate_early(self) -> None: + if self.multiworld.plando_connections[self.player]: + for index, cxn in enumerate(self.multiworld.plando_connections[self.player]): + # making shops second to simplify other things later + if cxn.entrance.startswith("Shop"): + replacement = PlandoConnection(cxn.exit, "Shop Portal", "both") + self.multiworld.plando_connections[self.player].remove(cxn) + self.multiworld.plando_connections[self.player].insert(index, replacement) + elif cxn.exit.startswith("Shop"): + replacement = PlandoConnection(cxn.entrance, "Shop Portal", "both") + self.multiworld.plando_connections[self.player].remove(cxn) + self.multiworld.plando_connections[self.player].insert(index, replacement) + # 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: @@ -74,6 +95,58 @@ def generate_early(self) -> None: self.options.entrance_rando.value = passthrough["entrance_rando"] self.options.shuffle_ladders.value = passthrough["shuffle_ladders"] + @classmethod + def stage_generate_early(cls, multiworld: MultiWorld) -> None: + tunic_worlds: Tuple[TunicWorld] = multiworld.get_game_worlds("TUNIC") + for tunic in tunic_worlds: + # if it's one of the options, then it isn't a custom seed group + if tunic.options.entrance_rando.value in EntranceRando.options: + continue + group = tunic.options.entrance_rando.value + # if this is the first world in the group, set the rules equal to its rules + if group not in cls.seed_groups: + cls.seed_groups[group] = SeedGroup(logic_rules=tunic.options.logic_rules.value, + laurels_at_10_fairies=tunic.options.laurels_location == 3, + fixed_shop=bool(tunic.options.fixed_shop), + plando=multiworld.plando_connections[tunic.player]) + continue + + # lower value is more restrictive + if tunic.options.logic_rules.value < cls.seed_groups[group]["logic_rules"]: + cls.seed_groups[group]["logic_rules"] = tunic.options.logic_rules.value + # laurels at 10 fairies changes logic for secret gathering place placement + if tunic.options.laurels_location == 3: + cls.seed_groups[group]["laurels_at_10_fairies"] = True + # fewer shops, one at windmill + if tunic.options.fixed_shop: + cls.seed_groups[group]["fixed_shop"] = True + + if multiworld.plando_connections[tunic.player]: + # loop through the connections in the player's yaml + for cxn in multiworld.plando_connections[tunic.player]: + new_cxn = True + for group_cxn in cls.seed_groups[group]["plando"]: + # if neither entrance nor exit match anything in the group, add to group + if ((cxn.entrance == group_cxn.entrance and cxn.exit == group_cxn.exit) + or (cxn.exit == group_cxn.entrance and cxn.entrance == group_cxn.exit)): + new_cxn = False + break + + # check if this pair is the same as a pair in the group already + is_mismatched = ( + cxn.entrance == group_cxn.entrance and cxn.exit != group_cxn.exit + or cxn.entrance == group_cxn.exit and cxn.exit != group_cxn.entrance + or cxn.exit == group_cxn.entrance and cxn.entrance != group_cxn.exit + or cxn.exit == group_cxn.exit and cxn.entrance != group_cxn.entrance + ) + if is_mismatched: + raise Exception(f"TUNIC: Conflict between seed group {group}'s plando " + f"connection {group_cxn.entrance} <-> {group_cxn.exit} and " + f"{tunic.multiworld.get_player_name(tunic.player)}'s plando " + f"connection {cxn.entrance} <-> {cxn.exit}") + if new_cxn: + cls.seed_groups[group]["plando"].append(cxn) + def create_item(self, name: str) -> TunicItem: item_data = item_table[name] return TunicItem(name, item_data.classification, self.item_name_to_id[name], self.player) diff --git a/worlds/tunic/er_scripts.py b/worlds/tunic/er_scripts.py index 3f70af83c0cc..323ccf421764 100644 --- a/worlds/tunic/er_scripts.py +++ b/worlds/tunic/er_scripts.py @@ -4,6 +4,7 @@ from .er_data import Portal, tunic_er_regions, portal_mapping, \ dependent_regions_restricted, dependent_regions_nmg, dependent_regions_ur from .er_rules import set_er_region_rules +from .options import EntranceRando from worlds.generic import PlandoConnection from random import Random @@ -128,12 +129,21 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: portal_pairs: Dict[Portal, Portal] = {} dead_ends: List[Portal] = [] two_plus: List[Portal] = [] - logic_rules = world.options.logic_rules.value player_name = world.multiworld.get_player_name(world.player) - + logic_rules = world.options.logic_rules.value + fixed_shop = world.options.fixed_shop + laurels_location = world.options.laurels_location + + # if it's not one of the EntranceRando options, it's a custom seed + if world.options.entrance_rando.value not in EntranceRando.options: + seed_group = world.seed_groups[world.options.entrance_rando.value] + logic_rules = seed_group["logic_rules"] + fixed_shop = seed_group["fixed_shop"] + laurels_location = "10_fairies" if seed_group["laurels_at_10_fairies"] is True else False + shop_scenes: Set[str] = set() shop_count = 6 - if world.options.fixed_shop.value: + if fixed_shop: shop_count = 1 shop_scenes.add("Overworld Redux") @@ -163,7 +173,10 @@ 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] + if world.options.entrance_rando.value in EntranceRando.options: + plando_connections = world.multiworld.plando_connections[world.player] + else: + plando_connections = world.seed_groups[world.options.entrance_rando.value]["plando"] # universal tracker support stuff, don't need to care about region dependency if hasattr(world.multiworld, "re_gen_passthrough"): @@ -198,10 +211,6 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: 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 @@ -213,7 +222,18 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: portal2 = portal # search dead_ends individually since we can't really remove items from two_plus during the loop - if not portal1: + if portal1: + two_plus.remove(portal1) + else: + # if not both, they're both dead ends + if not portal2: + if world.options.entrance_rando.value not in EntranceRando.options: + raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead " + "end to a dead end in their plando connections.") + else: + raise Exception(f"{player_name} paired a dead end to a dead end in their " + "plando connections.") + for portal in dead_ends: if p_entrance == portal.name: portal1 = portal @@ -222,16 +242,18 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: 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: + if portal2: + two_plus.remove(portal2) + else: + # check if portal2 is a dead end 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", + # if it's not a dead end, it might be a shop + if p_exit == "Shop Portal": + portal2 = Portal(name="Shop Portal", region="Shop", destination="Previous Region", tag="_") shop_count -= 1 if shop_count < 0: @@ -240,13 +262,12 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: if p.name == p_entrance: shop_scenes.add(p.scene()) break + # and if it's neither shop nor dead end, it just isn't correct 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 @@ -270,7 +291,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" and not hasattr(world.multiworld, "re_gen_passthrough"): + if laurels_location == "10_fairies" and not hasattr(world.multiworld, "re_gen_passthrough"): portal1 = None portal2 = None for portal in two_plus: @@ -291,7 +312,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: two_plus.remove(portal1) dead_ends.remove(portal2) - if world.options.fixed_shop and not hasattr(world.multiworld, "re_gen_passthrough"): + if fixed_shop and not hasattr(world.multiworld, "re_gen_passthrough"): portal1 = None for portal in two_plus: if portal.scene_destination() == "Overworld Redux, Windmill_": @@ -307,7 +328,8 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: two_plus.remove(portal1) random_object: Random = world.random - if world.options.entrance_rando.value != 1: + # use the seed given in the options to shuffle the portals + if isinstance(world.options.entrance_rando.value, str): random_object = Random(world.options.entrance_rando.value) # we want to start by making sure every region is accessible random_object.shuffle(two_plus) diff --git a/worlds/tunic/options.py b/worlds/tunic/options.py index 38ddcbe8e40f..9af0a0409c01 100644 --- a/worlds/tunic/options.py +++ b/worlds/tunic/options.py @@ -103,8 +103,10 @@ class ExtraHexagonPercentage(Range): class EntranceRando(TextChoice): """ Randomize the connections between scenes. - If you set this to a value besides true or false, that value will be used as a custom seed. A small, very lost fox on a big adventure. + + If you set this option's value to a string, it will be used as a custom seed. + Every player who uses the same custom seed will have the same entrances, choosing the most restrictive settings among these players for the purpose of pairing entrances. """ internal_name = "entrance_rando" display_name = "Entrance Rando"