Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TUNIC: Implement support for connection plando #2864

Merged
merged 11 commits into from
Mar 15, 2024
20 changes: 19 additions & 1 deletion worlds/tunic/docs/en_TUNIC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
ScipioWright marked this conversation as resolved.
Show resolved Hide resolved
- If you have a shop in every scene, and you have an odd number of shops, it will error out.
6 changes: 0 additions & 6 deletions worlds/tunic/er_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 0 additions & 13 deletions worlds/tunic/er_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
206 changes: 113 additions & 93 deletions worlds/tunic/er_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,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
Expand Down Expand Up @@ -215,19 +220,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
Expand All @@ -240,9 +243,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"):
ScipioWright marked this conversation as resolved.
Show resolved Hide resolved
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)
ScipioWright marked this conversation as resolved.
Show resolved Hide resolved

# if we have plando connections, our connected regions may change somewhat
while True:
Expand All @@ -255,7 +327,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:
Expand All @@ -266,34 +338,49 @@ 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)

# 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)
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:
Expand Down Expand Up @@ -327,15 +414,6 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
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")

# for universal tracker, we want to skip shop gen
if hasattr(world.multiworld, "re_gen_passthrough"):
if "TUNIC" in world.multiworld.re_gen_passthrough:
Expand All @@ -350,20 +428,24 @@ 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

# 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
Expand All @@ -381,7 +463,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"):
ScipioWright marked this conversation as resolved.
Show resolved Hide resolved
region2.connect(region1, f"{portal2.name} -> {portal1.name}")


Expand Down Expand Up @@ -507,65 +589,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
Loading