Files
Grinch-AP/worlds/tunic/er_scripts.py
Scipio Wright 6a63de2f0f TUNIC: Fuse and Bell Shuffle (#5420)
* Making the fix better (thanks medic)

* Make it actually return false if it gets to the backup lists and fails them

* Fix stuff after merge

* Add outlet regions, create new regions as needed for them

* Put together part of decoupled and direction pairs

* make direction pairs work

* Make decoupled work

* Make fixed shop work again

* Fix a few minor bugs

* Fix a few minor bugs

* Fix plando

* god i love programming

* Reorder portal list

* Update portal sorter for variable shops

* Add missing parameter

* Some cleanup of prints and functions

* Fix typo

* it's aliiiiiive

* Make seed groups not sync decoupled

* Add test with full-shop plando

* Fix bug with vanilla portals

* Handle plando connections and direction pair errors

* Update plando checking for decoupled

* Fix typo

* Fix exception text to be shorter

* Add some more comments

* Add todo note

* Remove unused safety thing

* Remove extra plando connections definition in options

* Make seed groups in decoupled with overlapping but not fully overlapped plando connections interact nicely without messing with what the entrances look like in the spoiler log

* Fix weird edge case that is technically user error

* Add note to fixed shop

* Fix parsing shop names in UT

* Remove debug print

* Actually make UT work

* multiworld. to world.

* Fix typo from merge

* Make it so the shops show up in the entrance hints

* Fix bug in ladder storage rules

* Remove blank line

* # Conflicts:
#	worlds/tunic/__init__.py
#	worlds/tunic/er_data.py
#	worlds/tunic/er_rules.py
#	worlds/tunic/er_scripts.py
#	worlds/tunic/rules.py
#	worlds/tunic/test/test_access.py

* Fix issues after merge

* Update plando connections stuff in docs

* Make early bushes only contain grass

* Fix library mistake

* Backport changes to grass rando (#20)

* Backport changes to grass rando

* add_rule instead of set_rule for the special cases, add special cases for back of swamp laurels area cause I should've made a new region for the swamp upper entrance

* Remove item name group for grass

* Update grass rando option descriptions

- Also ignore grass fill for single player games

* Ignore grass fill option for solo rando

* Update er_rules.py

* Fix pre fill issue

* Remove duplicate option

* Add excluded grass locations back

* Hide grass fill option from simple ui options page

* Check for start with sword before setting grass rules

* Update worlds/tunic/options.py

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* has_stick -> has_melee

* has_stick -> has_melee

* Add a failsafe for direction pairing

* Fix playthrough crash bug

* Remove init from logicmixin

* Updates per code review (thanks hesto)

* has_stick to has_melee in newer update

* has_stick to has_melee in newer update

* Exclude grass from get_filler_item_name

- non-grass rando games were accidentally seeing grass items get shuffled in as filler, which is funny but probably shouldn't happen

* Update worlds/tunic/__init__.py

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* Apply suggestions from code review

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* change the rest of grass_fill to local_fill

* Filter out grass from filler_items

* remove -> discard

* Update worlds/tunic/__init__.py

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Starting out

* Rules for breakable regions

* # Conflicts:
#	worlds/tunic/__init__.py
#	worlds/tunic/combat_logic.py
#	worlds/tunic/er_data.py
#	worlds/tunic/er_rules.py
#	worlds/tunic/er_scripts.py

* Cleanup more stuff after merge

* Revert "Cleanup more stuff after merge"

This reverts commit a6ee9a93da8f2fcc4413de6df6927b246017889d.

* Revert "# Conflicts:"

This reverts commit c74ccd74a45b6ad6b9abe6e339d115a0c98baf30.

* Cleanup more stuff after merge

* change has_stick to has_melee

* Update grass list with combat logic regions

* More fixes from combat logic merge

* Fix some dumb stuff (#21)

* Reorganize pre fill for grass

* make the rest of it work, it's pr ready, boom

* Make it work in not pot shuffle

* Merge grass rando

* multiworld -> world get_location, use has_any

* Swap out region for West Garden Before Terry grass

* Adjust west garden rules to add west combat region

* Adjust grass regions for south checkpoint grass

* Adjust grass regions for after terry grass

* Adjust grass regions for west combat grass

* Adjust grass regions for dagger house grass

* Adjust grass regions for south checkpoint grass, adjust regions and rules for some related locations

* Finish the remainder of the west garden grass, reformat ruined atoll a little

* More hex quest updates

- Implement page ability shuffle for hex quest
- Fix keys behind bosses if hex goal is less than 3
- Added check to fix conflicting hex quest options
- Add option to slot data

* Change option comparison

* Change option checking and fix some stuff

- also keep prayer first on low hex counts

* Update option defaulting

* Update option checking

* Fix option assignment again

* Merge in hex hunt

* Merge in changes

* Clean up imports

* Add ability type to UT stuff

* merge it all

* Make local fill work across pot and grass (to be adjusted later)

* Make separate pools for the grass and non-grass fills

* Fix id overlap

* Update option description

* Fix default

* Reorder localfill option desc

* Load the purgatory ones in

* Adjustments after merge

* Fully remove logicrules

* Fix UT support with fixed shop option

* Add breakable shuffle to the ut stuff

* Make it load in a specific number of locations

* Add Silent's spoiler log ability thing

* Fix for groups

* Fix for groups

* Fix typo

* Fix hex quest UT support

* Use .get

* UT fixes, classification fixes

* Rename some locations

* Adjust guard house names

* Adjust guard house names

* Rework create_item

* Fix for plando connections

* Rename, add new breakables

* Rename more stuff

* Time to rename them again

* Fix issue with fixed shop + decoupled

* Put in an exception to catch that error in the future

* Update create_item to match main

* Update spoiler log lines for hex abilities

* Burn the signs down

* Bring over the combat logic fix

* Merge in combat logic fix

* Silly static method thing

* Move a few areas to before well instead of east forest

* Add an all_random hidden option for dev stuff

* Port over changes from main

* Fix west courtyard pot regions

* Remove debug prints

* Fix fortress courtyard and beneath the fortress loc groups again

* Add exception handling to deal with duplicate apworlds

* Fix typo

* More missing loc group conversions

* Initial fuse shuffle stuff

* Fix gun missing from combat_items, add new for combat logic cache, very slight refactor of check_combat_reqs to let it do the changeover in a less complicated fashion, fix area being a boss area rather than non-boss area for a check

* Add fuse shuffle logic

* reorder atoll statue rule

* Update traversal reqs

* Remove fuse shuffle from temple door

* Combine rules and option checking

* Add bell shuffle; fix fuse location groups

* Fix portal rules not requiring prayer

* Merge the grass laurels exit grass PR

* Merge in fortress bridge PR

* Do a little clean up

* Fix a regression

* Update after merge

* Some more stuff

* More Silent changes

* Update more info section in game info page

* Fix rules for atoll and swamp fuses

* Precollect cathedral fuse in ER

* actually just make the fuse useful instead of progression

* Add it to the swamp and cath rules too

* Fix cath fuse name

* Minor fixes and edits

* Some UT stuff

* Fix a couple more groups

* Move a bunch of UT stuff to its own file

* Fix up a couple UT things

* Couple minor ER fixes

* Formatting change

* UT poptracker stuff enabled since it's optional in one of the releases

* Add author string to world class

* Adjust local fill option name

* Update ut_stuff to match the PR

* Add exception handling for UT with old apworld

* Fix missing tracker_world

* Remove extra entrance from cath main -> elevator

Entry <-> Elev exists,
Entry <-> Main exists
So no connection is needed between Main and Elev

* Fix so that decoupled doesn't incorrectly use get_portal_info and get_paired_portal

* Fix so that decoupled doesn't incorrectly use get_portal_info and get_paired_portal

* Update for breakables poptracker

* Backup and warnings instead

* Update typing

* Delete old regions and rules, move stuff to logic_helpers and constants

* Delete now much less useful tests

* Fix breakables map tracking

* Add more comments to init

* Add todo to grass.py

* Fix up tests

* Fully remove fixed_shop

* Finish hard deprecating FixedShop

* Fix zig skip showing up in decoupled fixed shop

* Make local_fill show up on the website

* Merge with main

* Fixes after merge

* More fixes after merge

* oh right that's why it was there, circular imports

* Swap {} to ()

* Add fuse and bell shuffle to seed groups since they're logically significant for entrance pairing

---------

Co-authored-by: silent-destroyer <osilentdestroyer@gmail.com>
Co-authored-by: Silent <110704408+silent-destroyer@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-09-30 21:39:41 +02:00

899 lines
45 KiB
Python

from copy import deepcopy
from random import Random
from typing import TYPE_CHECKING
from BaseClasses import Region, ItemClassification, Item, Location
from Options import PlandoConnection
from .breakables import create_breakable_exclusive_regions, set_breakable_location_rules
from .er_data import (Portal, portal_mapping, traversal_requirements, DeadEnd, Direction, RegionInfo,
get_portal_outlet_region)
from .er_rules import set_er_region_rules
from .locations import all_locations
from .options import EntranceRando, EntranceLayout
if TYPE_CHECKING:
from . import TunicWorld
class TunicERItem(Item):
game: str = "TUNIC"
class TunicERLocation(Location):
game: str = "TUNIC"
def create_er_regions(world: "TunicWorld") -> dict[Portal, Portal]:
regions: dict[str, Region] = {}
world.used_shop_numbers = set()
for region_name, region_data in world.er_regions.items():
if world.options.entrance_rando and region_name == "Zig Skip Exit":
# need to check if there's a seed group for this first
if world.options.entrance_rando.value not in EntranceRando.options.values():
if world.seed_groups[world.options.entrance_rando.value]["entrance_layout"] != EntranceLayout.option_fixed_shop:
continue
elif world.options.entrance_layout != EntranceLayout.option_fixed_shop:
continue
if not world.options.entrance_rando and region_name in ("Zig Skip Exit", "Purgatory"):
continue
region = Region(region_name, world.player, world.multiworld)
regions[region_name] = region
world.multiworld.regions.append(region)
if world.options.breakable_shuffle:
breakable_regions = create_breakable_exclusive_regions(world)
regions.update({region.name: region for region in breakable_regions})
if world.options.entrance_rando:
portal_pairs = pair_portals(world, regions)
# output the entrances to the spoiler log here for convenience
sorted_portal_pairs = sort_portals(portal_pairs, world)
if not world.options.decoupled:
for portal1, portal2 in sorted_portal_pairs.items():
world.multiworld.spoiler.set_entrance(portal1, portal2, "both", world.player)
else:
for portal1, portal2 in sorted_portal_pairs.items():
world.multiworld.spoiler.set_entrance(portal1, portal2, "entrance", world.player)
else:
portal_pairs = vanilla_portals(world, regions)
create_randomized_entrances(world, portal_pairs, regions)
set_er_region_rules(world, regions, portal_pairs)
for location_name, location_id in world.player_location_table.items():
region = regions[all_locations[location_name].er_region]
location = TunicERLocation(world.player, location_name, location_id, region)
region.locations.append(location)
if world.options.breakable_shuffle:
set_breakable_location_rules(world)
place_event_items(world, regions)
victory_region = regions["Spirit Arena Victory"]
victory_location = TunicERLocation(world.player, "The Heir", None, victory_region)
victory_location.place_locked_item(TunicERItem("Victory", ItemClassification.progression, None, world.player))
world.multiworld.completion_condition[world.player] = lambda state: state.has("Victory", world.player)
victory_region.locations.append(victory_location)
return portal_pairs
# keys are event names, values are event regions
tunic_events: dict[str, str] = {
"Eastern Bell": "Forest Belltower Upper",
"Western Bell": "Overworld Belltower at Bell",
"Furnace Fuse": "Furnace Fuse",
"South and West Fortress Exterior Fuses": "Fortress Exterior from Overworld",
"Upper and Central Fortress Exterior Fuses": "Fortress Courtyard Upper",
"Beneath the Vault Fuse": "Beneath the Vault Back",
"Eastern Vault West Fuses": "Eastern Vault Fortress",
"Eastern Vault East Fuse": "Eastern Vault Fortress",
"Quarry Connector Fuse": "Quarry Connector",
"Quarry Fuse": "Quarry Entry",
"Ziggurat Fuse": "Rooted Ziggurat Lower Back",
"West Garden Fuse": "West Garden South Checkpoint",
"Library Fuse": "Library Lab",
"Place Questagons": "Sealed Temple",
}
def place_event_items(world: "TunicWorld", regions: dict[str, Region]) -> None:
for event_name, region_name in tunic_events.items():
region = regions[region_name]
location = TunicERLocation(world.player, event_name, None, region)
if event_name == "Place Questagons":
if world.options.hexagon_quest:
continue
location.place_locked_item(
TunicERItem("Unseal the Heir", ItemClassification.progression, None, world.player))
elif event_name.endswith("Bell"):
if world.options.shuffle_bells:
continue
location.place_locked_item(
TunicERItem("Ring " + event_name, ItemClassification.progression, None, world.player))
elif event_name.endswith("Fuse") or event_name.endswith("Fuses"):
if world.options.shuffle_fuses:
continue
location.place_locked_item(
TunicERItem("Activate " + event_name, ItemClassification.progression, None, world.player))
region.locations.append(location)
# keeping track of which shop numbers have been used already to avoid duplicates
# due to plando, shops can be added out of order, so a set is the best way to make this work smoothly
def get_shop_num(world: "TunicWorld") -> int:
portal_num = -1
for i in range(500):
if i + 1 not in world.used_shop_numbers:
portal_num = i + 1
world.used_shop_numbers.add(portal_num)
break
if portal_num == -1:
raise Exception(f"TUNIC: {world.player_name} has plando'd too many shops.")
return portal_num
# all shops are the same shop. however, you cannot get to all shops from the same shop entrance.
# so, we need a bunch of shop regions that connect to the actual shop, but the actual shop cannot connect back
def create_shop_region(world: "TunicWorld", regions: dict[str, Region], portal_num) -> None:
new_shop_name = f"Shop {portal_num}"
world.er_regions[new_shop_name] = RegionInfo("Shop", dead_end=DeadEnd.all_cats)
new_shop_region = Region(new_shop_name, world.player, world.multiworld)
new_shop_region.connect(regions["Shop"])
regions[new_shop_name] = new_shop_region
# for non-ER that uses the ER rules, we create a vanilla set of portal pairs
def vanilla_portals(world: "TunicWorld", regions: dict[str, Region]) -> dict[Portal, Portal]:
portal_pairs: dict[Portal, Portal] = {}
# we don't want the zig skip exit for vanilla portals, since it shouldn't be considered for logic here
portal_map = [portal for portal in portal_mapping if portal.name not in
["Ziggurat Lower Falling Entrance", "Purgatory Bottom Exit", "Purgatory Top Exit"]]
while portal_map:
portal1 = portal_map[0]
portal2 = None
# portal2 scene destination tag is portal1's destination scene tag
portal2_sdt = portal1.destination_scene()
if portal2_sdt.startswith("Shop,"):
portal_num = get_shop_num(world)
portal2 = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
destination=str(portal_num), tag="_", direction=Direction.none)
create_shop_region(world, regions, portal_num)
for portal in portal_map:
if portal.scene_destination() == portal2_sdt:
portal2 = portal
break
portal_pairs[portal1] = portal2
portal_map.remove(portal1)
if not portal2_sdt.startswith("Shop,"):
portal_map.remove(portal2)
return portal_pairs
# the really long function that gives us our portal pairs
# before we start pairing, we separate the portals into dead ends and non-dead ends (two_plus)
# then, we do a few other important tasks to accommodate options and seed gropus
# first phase: pick a two_plus in a reachable region and non-reachable region and pair them
# repeat this phase until all regions are reachable
# second phase: randomly pair dead ends to random two_plus
# third phase: randomly pair the remaining two_plus to each other
def pair_portals(world: "TunicWorld", regions: dict[str, Region]) -> dict[Portal, Portal]:
portal_pairs: dict[Portal, Portal] = {}
dead_ends: list[Portal] = []
two_plus: list[Portal] = []
player_name = world.player_name
portal_map = portal_mapping.copy()
laurels_zips = world.options.laurels_zips.value
ice_grappling = world.options.ice_grappling.value
ladder_storage = world.options.ladder_storage.value
entrance_layout = world.options.entrance_layout
laurels_location = world.options.laurels_location
decoupled = world.options.decoupled
shuffle_fuses = bool(world.options.shuffle_fuses.value)
shuffle_bells = bool(world.options.shuffle_bells.value)
traversal_reqs = deepcopy(traversal_requirements)
has_laurels = True
waterfall_plando = False
# if it's not one of the EntranceRando options, it's a custom seed
if world.options.entrance_rando.value not in EntranceRando.options.values():
seed_group = world.seed_groups[world.options.entrance_rando.value]
laurels_zips = seed_group["laurels_zips"]
ice_grappling = seed_group["ice_grappling"]
ladder_storage = seed_group["ladder_storage"]
entrance_layout = seed_group["entrance_layout"]
laurels_location = "10_fairies" if seed_group["laurels_at_10_fairies"] is True else False
shuffle_bells = seed_group["bell_shuffle"]
shuffle_fuses = seed_group["fuse_shuffle"]
logic_tricks: tuple[bool, int, int] = (laurels_zips, ice_grappling, ladder_storage)
# marking that you don't immediately have laurels
if laurels_location == "10_fairies" and not world.using_ut:
has_laurels = False
# for the direction pairs option with decoupled off
# tracks how many portals are in each direction in each list
two_plus_direction_tracker: dict[int, int] = {direction: 0 for direction in range(8)}
dead_end_direction_tracker: dict[int, int] = {direction: 0 for direction in range(8)}
# for ensuring we have enough entrances in directions left that we don't leave dead ends without any
def too_few_portals_for_direction_pairs(direction: int, offset: int) -> bool:
if two_plus_direction_tracker[direction] <= (dead_end_direction_tracker[direction_pairs[direction]] + offset):
return False
if two_plus_direction_tracker[direction_pairs[direction]] <= dead_end_direction_tracker[direction] + offset:
return False
return True
# create separate lists for dead ends and non-dead ends
for portal in portal_map:
dead_end_status = world.er_regions[portal.region].dead_end
if dead_end_status == DeadEnd.free:
two_plus.append(portal)
two_plus_direction_tracker[portal.direction] += 1
elif dead_end_status == DeadEnd.all_cats:
dead_ends.append(portal)
dead_end_direction_tracker[portal.direction] += 1
elif dead_end_status == DeadEnd.restricted:
if ice_grappling:
two_plus.append(portal)
two_plus_direction_tracker[portal.direction] += 1
else:
dead_ends.append(portal)
dead_end_direction_tracker[portal.direction] += 1
# these two get special handling
elif dead_end_status == DeadEnd.special:
if portal.region == "Secret Gathering Place":
if laurels_location == "10_fairies":
two_plus.append(portal)
two_plus_direction_tracker[portal.direction] += 1
else:
dead_ends.append(portal)
dead_end_direction_tracker[portal.direction] += 1
if (portal.region == "Zig Skip Exit" and entrance_layout == EntranceLayout.option_fixed_shop
and not decoupled):
# direction isn't meaningful here since zig skip cannot be in direction pairs mode
# don't add it in decoupled
two_plus.append(portal)
# now we generate the shops and add them to the dead ends list
shop_count = 6
if entrance_layout == EntranceLayout.option_fixed_shop:
shop_count = 0
else:
# if fixed shop is off, remove this portal
for portal in portal_map:
if portal.region == "Zig Skip Exit":
portal_map.remove(portal)
break
# need 8 shops with direction pairs or there won't be a valid set of pairs
if entrance_layout == EntranceLayout.option_direction_pairs:
shop_count = 8
# for universal tracker, we want to skip shop gen since it's essentially full plando
if world.using_ut:
shop_count = 0
for _ in range(shop_count):
# 6 of the shops have south exits, 2 of them have west exits
portal_num = get_shop_num(world)
shop_dir = Direction.south
if portal_num > 6:
shop_dir = Direction.west
shop_portal = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
destination=str(portal_num), tag="_", direction=shop_dir)
create_shop_region(world, regions, portal_num)
dead_ends.append(shop_portal)
dead_end_direction_tracker[shop_portal.direction] += 1
connected_regions: set[str] = set()
# make better start region stuff when/if implementing random start
start_region = "Overworld"
connected_regions.add(start_region)
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks,
shuffle_fuses, shuffle_bells)
if world.options.entrance_rando.value in EntranceRando.options.values():
plando_connections = world.options.plando_connections.value
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 world.using_ut:
plando_connections.clear()
# universal tracker stuff, won't do anything in normal gen
for portal1, portal2 in world.passthrough["Entrance Rando"].items():
portal_name1 = ""
portal_name2 = ""
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_name1 and portal1.startswith("Shop"):
# it should show up as "Shop, 1_" for shop 1
portal_name1 = "Shop Portal " + str(portal1).split(", ")[1].split("_")[0]
if not portal_name2 and portal2.startswith("Shop"):
portal_name2 = "Shop Portal " + str(portal2).split(", ")[1].split("_")[0]
if world.options.decoupled:
plando_connections.append(PlandoConnection(portal_name1, portal_name2, "entrance"))
else:
plando_connections.append(PlandoConnection(portal_name1, portal_name2, "both"))
# put together the list of non-deadend regions
non_dead_end_regions = set()
for region_name, region_info in world.er_regions.items():
# these are not real regions, they are just here to be descriptive
if region_info.is_fake_region or region_name == "Shop":
continue
# dead ends aren't real in decoupled
if decoupled:
non_dead_end_regions.add(region_name)
elif not region_info.dead_end:
non_dead_end_regions.add(region_name)
# if ice grappling to places is in logic, both places stop being dead ends
elif region_info.dead_end == DeadEnd.restricted and ice_grappling:
non_dead_end_regions.add(region_name)
# secret gathering place is treated as a non-dead end if 10 fairies is on to assure non-laurels access to it
elif region_info.dead_end == DeadEnd.special:
if region_name == "Secret Gathering Place" and laurels_location == "10_fairies":
non_dead_end_regions.add(region_name)
if decoupled:
# add the dead ends to the two plus list, since dead ends aren't real in decoupled
two_plus.extend(dead_ends)
dead_ends.clear()
# if decoupled is on, we make a second two_plus list, where the first is entrances and the second is exits
two_plus2 = two_plus.copy()
else:
# if decoupled is off, the two lists are the same list, since entrances and exits are intertwined
two_plus2 = two_plus
if plando_connections:
if decoupled:
modified_plando_connections = plando_connections.copy()
for index, cxn in enumerate(modified_plando_connections):
# it's much easier if we split both-direction portals into two one-ways in decoupled
if cxn.direction == "both":
replacement1 = PlandoConnection(cxn.entrance, cxn.exit, "entrance")
replacement2 = PlandoConnection(cxn.exit, cxn.entrance, "entrance")
modified_plando_connections.remove(cxn)
modified_plando_connections.insert(index, replacement1)
modified_plando_connections.append(replacement2)
else:
modified_plando_connections = plando_connections
connected_shop_portal1s: set[int] = set()
connected_shop_portal2s: set[int] = set()
for connection in modified_plando_connections:
p_entrance = connection.entrance
p_exit = connection.exit
# if you plando secret gathering place, need to know that during portal pairing
if p_exit == "Secret Gathering Place Exit":
waterfall_plando = True
if p_entrance == "Secret Gathering Place Exit" and not decoupled:
waterfall_plando = True
portal1_dead_end = True
portal2_dead_end = True
portal1 = None
portal2 = None
# search the two_plus lists (or list) for the portals
for portal in two_plus:
if p_entrance == portal.name:
portal1 = portal
portal1_dead_end = False
break
for portal in two_plus2:
if p_exit == portal.name:
portal2 = portal
portal2_dead_end = False
break
# search dead_ends individually since we can't really remove items from two_plus during the loop
if portal1:
two_plus.remove(portal1)
else:
# if not both, they're both dead ends
if not portal2 and not decoupled:
if world.options.entrance_rando.value not in EntranceRando.options.values():
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 "
f"plando connections -- {connection.entrance} to {connection.exit}")
for portal in dead_ends:
if p_entrance == portal.name:
portal1 = portal
dead_ends.remove(portal1)
break
else:
if p_entrance.startswith("Shop Portal "):
try:
portal_num = int(p_entrance.split("Shop Portal ")[-1])
except ValueError:
if "Previous Region" in p_entrance:
raise Exception("TUNIC: APWorld used for generation is incompatible with newer APWorld. "
"Please use the APWorld from Archipelago 0.6.1 instead.")
else:
raise Exception("TUNIC: Unknown error occurred in UT entrance setup, please contact "
"the TUNIC APWorld devs.")
# shops 1-6 are south, 7 and 8 are east, and after that it just breaks direction pairs
if portal_num <= 6:
pdir = Direction.south
elif portal_num in [7, 8]:
pdir = Direction.east
else:
pdir = Direction.none
portal1 = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
destination=str(portal_num), tag="_", direction=pdir)
connected_shop_portal1s.add(portal_num)
if portal_num not in world.used_shop_numbers:
create_shop_region(world, regions, portal_num)
world.used_shop_numbers.add(portal_num)
if decoupled and portal_num not in connected_shop_portal2s:
two_plus2.append(portal1)
non_dead_end_regions.add(portal1.region)
else:
raise Exception(f"Could not find entrance named {p_entrance} for "
f"plando connections in {player_name}'s YAML.")
if portal2:
two_plus2.remove(portal2)
else:
for portal in dead_ends:
if p_exit == portal.name:
portal2 = portal
dead_ends.remove(portal2)
break
# if it's not a dead end, maybe it's a plando'd shop portal that doesn't normally exist
else:
if not portal2:
if p_exit.startswith("Shop Portal "):
try:
portal_num = int(p_exit.split("Shop Portal ")[-1])
except ValueError:
if "Previous Region" in p_exit:
raise Exception("TUNIC: APWorld used for generation is incompatible with newer APWorld. "
"Please use the APWorld from Archipelago 0.6.1 instead.")
else:
raise Exception("TUNIC: Unknown error occurred in UT entrance setup, please contact "
"the TUNIC APWorld devs.")
if portal_num <= 6:
pdir = Direction.south
elif portal_num in [7, 8]:
pdir = Direction.east
else:
pdir = Direction.none
portal2 = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
destination=str(portal_num), tag="_", direction=pdir)
connected_shop_portal2s.add(portal_num)
if portal_num not in world.used_shop_numbers:
create_shop_region(world, regions, portal_num)
world.used_shop_numbers.add(portal_num)
if decoupled and portal_num not in connected_shop_portal1s:
two_plus.append(portal2)
non_dead_end_regions.add(portal2.region)
else:
raise Exception(f"Could not find entrance named {p_exit} for "
f"plando connections in {player_name}'s YAML.")
# if we're doing decoupled, we don't need to do complex checks
if decoupled:
# we turn any plando that uses "exit" to use "entrance" instead
traversal_reqs.setdefault(portal1.region, dict())[get_portal_outlet_region(portal2, world)] = []
# outside decoupled, we want to use what we were doing before decoupled got added
else:
# update the traversal chart to say you can get from portal1's region to portal2's and vice versa
if not portal1_dead_end and not portal2_dead_end:
traversal_reqs.setdefault(portal1.region, dict())[get_portal_outlet_region(portal2, world)] = []
traversal_reqs.setdefault(portal2.region, dict())[get_portal_outlet_region(portal1, world)] = []
if (portal1.region == "Zig Skip Exit" and (portal2_dead_end or portal2.region == "Secret Gathering Place")
or portal2.region == "Zig Skip Exit" and (portal1_dead_end or portal1.region == "Secret Gathering Place")):
if world.options.entrance_rando.value not in EntranceRando.options.values():
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.")
if (portal1.region == "Secret Gathering Place" and (portal2_dead_end or portal2.region == "Zig Skip Exit")
or portal2.region == "Secret Gathering Place" and (portal1_dead_end or portal1.region == "Zig Skip Exit")):
# need to make sure you didn't pair this to a dead end or zig skip
if portal1_dead_end or portal2_dead_end or \
portal1.region == "Zig Skip Exit" or portal2.region == "Zig Skip Exit":
if world.options.entrance_rando.value not in EntranceRando.options.values():
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.")
# okay now that we're done with all of that nonsense, we can finally make the portal pair
portal_pairs[portal1] = portal2
if portal1_dead_end:
dead_end_direction_tracker[portal1.direction] -= 1
else:
two_plus_direction_tracker[portal1.direction] -= 1
if portal2_dead_end:
dead_end_direction_tracker[portal2.direction] -= 1
else:
two_plus_direction_tracker[portal2.direction] -= 1
# if we have plando connections, our connected regions may change somewhat
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks,
shuffle_fuses, shuffle_bells)
# if there are an odd number of shops after plando, add another one, except in decoupled where it doesn't matter
if not decoupled and len(world.used_shop_numbers) % 2 == 1:
if entrance_layout == EntranceLayout.option_direction_pairs:
raise Exception(f"TUNIC: {world.player_name} plando'd too many shops for the Direction Pairs option.")
portal_num = get_shop_num(world)
shop_portal = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
destination=str(portal_num), tag="_", direction=Direction.none)
create_shop_region(world, regions, portal_num)
dead_ends.append(shop_portal)
if entrance_layout == EntranceLayout.option_fixed_shop and not world.using_ut:
windmill = None
for portal in two_plus:
if portal.scene_destination() == "Overworld Redux, Windmill_":
windmill = portal
break
if not windmill:
raise Exception(f"Failed to do Fixed Shop option for Entrance Layout. "
f"Did {player_name} plando the Windmill Shop entrance?")
portal_num = get_shop_num(world)
shop = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
destination=str(portal_num), tag="_", direction=Direction.south)
create_shop_region(world, regions, portal_num)
portal_pairs[windmill] = shop
two_plus.remove(windmill)
if decoupled:
two_plus.append(shop)
non_dead_end_regions.add(shop.region)
connected_regions.add(shop.region)
# 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)
else:
random_object: Random = world.random
# we want to start by making sure every region is accessible
random_object.shuffle(two_plus)
# this is a backup in case we run into that rare direction pairing failure
# so that we don't have to redo the plando bit basically
backup_connected_regions = connected_regions.copy()
backup_portal_pairs = portal_pairs.copy()
backup_two_plus = two_plus.copy()
backup_two_plus_direction_tracker = two_plus_direction_tracker.copy()
rare_failure_count = 0
portal1 = None
portal2 = None
previous_conn_num = 0
fail_count = 0
while len(connected_regions) < len(non_dead_end_regions):
# if this is universal tracker, just break immediately and move on
if world.using_ut:
break
# 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 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 errors. "
f"Unconnected regions: {non_dead_end_regions - connected_regions}.\n"
f"Unconnected portals: {[portal.name for portal in two_plus]}")
if (fail_count > 100 and not decoupled
and (world.options.entrance_layout == EntranceLayout.option_direction_pairs or waterfall_plando)):
# in direction pairs, we may run into a case where we run out of pairable directions
# since we need to ensure the dead ends will have something to connect to
# or if fairy cave is plando'd, it may run into an issue where it is trying to get access to 2 separate
# areas at once to give access to laurels
# so, this is basically just resetting entrance pairing
# this should be very rare, so this fail-safe shouldn't be covering up for an actual solution
# this should never happen in decoupled, since it's entirely too flexible for that
portal_pairs = backup_portal_pairs.copy()
two_plus = two_plus2 = backup_two_plus.copy()
two_plus_direction_tracker = backup_two_plus_direction_tracker.copy()
random_object.shuffle(two_plus)
connected_regions = backup_connected_regions.copy()
rare_failure_count += 1
fail_count = 0
if rare_failure_count > 100:
raise Exception(f"Failed to pair regions due to rare pairing issues for {player_name}. "
f"Unconnected regions: {non_dead_end_regions - connected_regions}.\n"
f"Unconnected portals: {[portal.name for portal in two_plus]}")
else:
fail_count = 0
previous_conn_num = len(connected_regions)
# find a portal in a connected region
for portal in two_plus:
if portal.region in connected_regions:
# if there's more dead ends of a direction than two plus of the opposite direction,
# then we'll run out of viable connections for those dead ends later
# decoupled does not have this issue since dead ends aren't real in decoupled
if not decoupled and entrance_layout == EntranceLayout.option_direction_pairs:
if not too_few_portals_for_direction_pairs(portal.direction, 0):
continue
portal1 = portal
two_plus.remove(portal)
break
if not portal1:
raise Exception("TUNIC: Failed to pair portals at first part of first phase.")
# then we find a portal in an unconnected region
for portal in two_plus2:
if portal.region not in connected_regions:
# if secret gathering place happens to get paired really late, you can end up running out
if not has_laurels and len(two_plus2) < 80:
# if you plando'd secret gathering place with laurels at 10 fairies, you're the reason for this
if waterfall_plando:
cr = connected_regions.copy()
cr.add(portal.region)
if "Secret Gathering Place" not in update_reachable_regions(cr, traversal_reqs, has_laurels,
logic_tricks, shuffle_fuses,
shuffle_bells):
continue
# if not waterfall_plando, then we just want to pair secret gathering place now
elif portal.region != "Secret Gathering Place":
continue
# if they're not facing opposite directions, just continue
if entrance_layout == EntranceLayout.option_direction_pairs and not verify_direction_pair(portal, portal1):
continue
# if you have direction pairs, we need to make sure we don't run out of spots for problem portals
# this cuts down on using the failsafe significantly
if not decoupled and entrance_layout == EntranceLayout.option_direction_pairs:
should_continue = False
# these portals are weird since they're one-ways essentially
# we need to make sure they are connected in this first phase
south_problems = ["Ziggurat Upper to Ziggurat Entry Hallway",
"Ziggurat Tower to Ziggurat Upper", "Forest Belltower to Guard Captain Room"]
if (portal.direction == Direction.south and portal.name not in south_problems
and not too_few_portals_for_direction_pairs(portal.direction, 3)):
for test_portal in two_plus:
if test_portal.name in south_problems:
should_continue = True
# at risk of connecting frog's domain entry ladder to librarian exit
if (portal.direction == Direction.ladder_down
or portal.direction == Direction.ladder_up and portal.name != "Frog's Domain Ladder Exit"
and not too_few_portals_for_direction_pairs(portal.direction, 1)):
for test_portal in two_plus:
if test_portal.name == "Frog's Domain Ladder Exit":
should_continue = True
if should_continue:
continue
portal2 = portal
connected_regions.add(get_portal_outlet_region(portal, world))
two_plus2.remove(portal)
break
if not portal2:
if entrance_layout == EntranceLayout.option_direction_pairs or waterfall_plando:
# portal1 doesn't have a valid direction pair yet, throw it back and start over
two_plus.append(portal1)
continue
else:
raise Exception(f"TUNIC: Failed to pair portals at second part of first phase for {world.player_name}.")
# once we have both portals, connect them and add the new region(s) to connected_regions
if not has_laurels and "Secret Gathering Place" in connected_regions:
has_laurels = True
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks,
shuffle_fuses, shuffle_bells)
portal_pairs[portal1] = portal2
two_plus_direction_tracker[portal1.direction] -= 1
two_plus_direction_tracker[portal2.direction] -= 1
portal1 = None
portal2 = None
random_object.shuffle(two_plus)
if two_plus != two_plus2:
random_object.shuffle(two_plus2)
# connect dead ends to random non-dead ends
# there are no dead ends in decoupled
while len(dead_ends) > 0:
if world.using_ut:
break
portal2 = dead_ends[0]
for portal in two_plus:
if entrance_layout == EntranceLayout.option_direction_pairs and not verify_direction_pair(portal, portal2):
continue
if entrance_layout == EntranceLayout.option_fixed_shop and portal.region == "Zig Skip Exit":
continue
portal1 = portal
portal_pairs[portal1] = portal2
two_plus.remove(portal1)
dead_ends.remove(portal2)
break
else:
raise Exception(f"Failed to pair {portal2.name} with anything in two_plus for player {world.player_name}.")
# then randomly connect the remaining portals to each other
final_pair_number = 0
while len(two_plus) > 0:
if world.using_ut:
break
final_pair_number += 1
if final_pair_number > 10000:
raise Exception(f"Failed to pair portals while pairing the final entrances off to each other. "
f"Remaining portals in two_plus: {[portal.name for portal in two_plus]}. "
f"Remaining portals in two_plus2: {[portal.name for portal in two_plus2]}.")
portal1 = two_plus[0]
two_plus.remove(portal1)
portal2 = None
if entrance_layout != EntranceLayout.option_direction_pairs:
portal2 = two_plus2.pop()
else:
for portal in two_plus2:
if verify_direction_pair(portal1, portal):
portal2 = portal
two_plus2.remove(portal2)
break
if portal2 is None:
raise Exception("Something went wrong with the remaining two plus portals. Contact the TUNIC rando devs.")
portal_pairs[portal1] = portal2
if len(two_plus2) > 0:
raise Exception(f"TUNIC: Something went horribly wrong in ER for {world.player_name}. "
f"Please contact the TUNIC rando devs.")
return portal_pairs
# loop through our list of paired portals and make two-way connections
def create_randomized_entrances(world: "TunicWorld", portal_pairs: dict[Portal, Portal], regions: dict[str, Region]) -> None:
for portal1, portal2 in portal_pairs.items():
# connect to the outlet region if there is one, if not connect to the actual region
regions[portal1.region].connect(
connecting_region=regions[get_portal_outlet_region(portal2, world)],
name=portal1.name)
if not world.options.decoupled or not world.options.entrance_rando:
regions[portal2.region].connect(
connecting_region=regions[get_portal_outlet_region(portal1, world)],
name=portal2.name)
def update_reachable_regions(connected_regions: set[str], traversal_reqs: dict[str, dict[str, list[list[str]]]],
has_laurels: bool, logic: tuple[bool, int, int], shuffle_fuses: bool,
shuffle_bells: bool) -> set[str]:
zips, ice_grapples, ls = logic
# starting count, so we can run it again if this changes
region_count = len(connected_regions)
for origin, destinations in traversal_reqs.items():
if origin not in connected_regions:
continue
# check if we can traverse to any of the destinations
for destination, req_lists in destinations.items():
if destination in connected_regions:
continue
met_traversal_reqs = False
if len(req_lists) == 0:
met_traversal_reqs = True
# loop through each set of possible requirements, with a fancy for else loop
for reqs in req_lists:
for req in reqs:
if req == "Hyperdash":
if not has_laurels:
break
elif req == "Zip":
if not zips:
break
# if req is higher than logic option, then it breaks since it's not a valid connection
elif req.startswith("IG"):
if int(req[-1]) > ice_grapples:
break
elif req.startswith("LS"):
if int(req[-1]) > ls:
break
elif req not in connected_regions:
break
elif req == "Fuse Shuffle":
if not shuffle_fuses:
break
elif req == "Bell Shuffle":
if not shuffle_bells:
break
else:
met_traversal_reqs = True
break
if met_traversal_reqs:
connected_regions.add(destination)
# if the length of connected_regions changed, we got new regions, so we want to check those new origins
if region_count != len(connected_regions):
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic,
shuffle_fuses, shuffle_bells)
return connected_regions
# which directions are opposites
direction_pairs: dict[int, int] = {
Direction.north: Direction.south,
Direction.south: Direction.north,
Direction.east: Direction.west,
Direction.west: Direction.east,
Direction.ladder_up: Direction.ladder_down,
Direction.ladder_down: Direction.ladder_up,
Direction.floor: Direction.floor,
}
# verify that two portals are in compatible directions
def verify_direction_pair(portal1: Portal, portal2: Portal) -> bool:
return portal1.direction == direction_pairs[portal2.direction]
# verify that two plando'd portals are in compatible directions
def verify_plando_directions(connection: PlandoConnection) -> bool:
entrance_portal = None
exit_portal = None
for portal in portal_mapping:
if connection.entrance == portal.name:
entrance_portal = portal
if connection.exit == portal.name:
exit_portal = portal
if entrance_portal and exit_portal:
break
# neither of these are shops, so verify the pair
if entrance_portal and exit_portal:
return verify_direction_pair(entrance_portal, exit_portal)
# this is two shop portals, they can never pair directions
elif not entrance_portal and not exit_portal:
return False
# if one of them is none, it's a shop, which has two possible directions
elif not entrance_portal:
return exit_portal.direction in [Direction.north, Direction.east]
elif not exit_portal:
return entrance_portal.direction in [Direction.north, Direction.east]
else:
# shouldn't be reachable, more of a just in case
raise Exception("Something went very wrong with verify_plando_directions")
# sort the portal dict by the name of the first portal, referring to the portal order in the master portal list
def sort_portals(portal_pairs: dict[Portal, Portal], world: "TunicWorld") -> dict[str, str]:
sorted_pairs: dict[str, str] = {}
reference_list: list[str] = [portal.name for portal in portal_mapping]
# due to plando, there can be a variable number of shops
largest_shop_number = max(world.used_shop_numbers)
reference_list.extend([f"Shop Portal {i + 1}" for i in range(largest_shop_number)])
for name in reference_list:
for portal1, portal2 in portal_pairs.items():
if name == portal1.name:
sorted_pairs[portal1.name] = portal2.name
break
return sorted_pairs