TUNIC: Implement support for connection plando (#2864)
This commit is contained in:
parent
2a8784ef72
commit
e6198585c8
|
@ -226,7 +226,7 @@ class TunicWorld(World):
|
|||
"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)"],
|
||||
|
|
|
@ -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.
|
||||
- If you have a shop in every scene, and you have an odd number of shops, it will error out.
|
||||
|
|
|
@ -682,12 +682,6 @@ tunic_er_regions: Dict[str, RegionInfo] = {
|
|||
"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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -5,6 +5,7 @@ from .er_data import Portal, tunic_er_regions, portal_mapping, hallway_helper, h
|
|||
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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -59,7 +59,7 @@ class TestNormalGoal(TunicTestBase):
|
|||
|
||||
|
||||
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}
|
||||
|
||||
|
|
Loading…
Reference in New Issue