TUNIC: The Big Refactor (#5195)

* 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

* Pull out fuse and bell shuffle

* Pull out fuse and bell shuffle

* Update worlds/tunic/options.py

Co-authored-by: qwint <qwint.42@gmail.com>

* Update worlds/tunic/logic_helpers.py

Co-authored-by: qwint <qwint.42@gmail.com>

* {} -> () in state functions

* {} -> () in state functions

* Change {} -> () in state functions, use constant for gun

* Remove floating constants in er_data

* Finish hard deprecating FixedShop

* Finish hard deprecating FixedShop

* Fix zig skip showing up in decoupled fixed shop

---------

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>
Co-authored-by: qwint <qwint.42@gmail.com>
This commit is contained in:
Scipio Wright
2025-09-04 18:44:32 -04:00
committed by GitHub
parent 42ace29db4
commit b0b3e3668f
17 changed files with 749 additions and 918 deletions

View File

@@ -1,25 +1,28 @@
from dataclasses import fields
from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union, Set, TextIO
from logging import warning
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
from typing import Any, TypedDict, ClassVar, TextIO
from BaseClasses import Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
from Options import PlandoConnection, OptionError, PerGameCommonOptions, Range, Removed
from settings import Group, Bool, FilePath
from worlds.AutoWorld import WebWorld, World
# from .bells import bell_location_groups, bell_location_name_to_id
from .breakables import breakable_location_name_to_id, breakable_location_groups, breakable_location_table
from .combat_logic import area_data, CombatState
from .er_data import portal_mapping, RegionInfo, tunic_er_regions
from .er_rules import set_er_location_rules
from .er_scripts import create_er_regions, verify_plando_directions
# from .fuses import fuse_location_name_to_id, fuse_location_groups
from .grass import grass_location_table, grass_location_name_to_id, grass_location_name_groups, excluded_grass_locations
from .items import (item_name_to_id, item_table, item_name_groups, fool_tiers, filler_items, slot_data_item_names,
combat_items)
from .locations import location_table, location_name_groups, standard_location_name_to_id, hexagon_locations
from .rules import set_location_rules, set_region_rules, randomize_ability_unlocks, gold_hexagon
from .er_rules import set_er_location_rules
from .regions import tunic_regions
from .er_scripts import create_er_regions, verify_plando_directions
from .grass import grass_location_table, grass_location_name_to_id, grass_location_name_groups, excluded_grass_locations
from .er_data import portal_mapping, RegionInfo, tunic_er_regions
from .logic_helpers import randomize_ability_unlocks, gold_hexagon
from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections,
LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage, check_options,
get_hexagons_in_pool, HexagonQuestAbilityUnlockType, EntranceLayout)
from .breakables import breakable_location_name_to_id, breakable_location_groups, breakable_location_table
from .combat_logic import area_data, CombatState
LaurelsLocation, LaurelsZips, IceGrappling, LadderStorage, EntranceLayout,
check_options, LocalFill, get_hexagons_in_pool, HexagonQuestAbilityUnlockType)
from . import ut_stuff
from worlds.AutoWorld import WebWorld, World
from Options import PlandoConnection, OptionError, PerGameCommonOptions, Removed, Range
from settings import Group, Bool, FilePath
class TunicSettings(Group):
@@ -28,15 +31,15 @@ class TunicSettings(Group):
class LimitGrassRando(Bool):
"""Limits the impact of Grass Randomizer on the multiworld by disallowing local_fill percentages below 95."""
class UTPoptrackerPath(FilePath):
"""Path to the user's TUNIC Poptracker Pack."""
description = "TUNIC Poptracker Pack zip file"
required = False
disable_local_spoiler: Union[DisableLocalSpoiler, bool] = False
limit_grass_rando: Union[LimitGrassRando, bool] = True
ut_poptracker_path: Union[UTPoptrackerPath, str] = UTPoptrackerPath()
disable_local_spoiler: DisableLocalSpoiler | bool = False
limit_grass_rando: LimitGrassRando | bool = True
ut_poptracker_path: UTPoptrackerPath | str = UTPoptrackerPath()
class TunicWeb(WebWorld):
@@ -68,10 +71,10 @@ class SeedGroup(TypedDict):
laurels_zips: bool # laurels_zips value
ice_grappling: int # ice_grappling value
ladder_storage: int # ls value
laurels_at_10_fairies: bool # laurels location value
laurels_at_10_fairies: bool # whether laurels location is set to 10 fairies
entrance_layout: int # entrance layout value
has_decoupled_enabled: bool # for checking that players don't have conflicting options
plando: List[PlandoConnection] # consolidated plando connections for the seed group
plando: list[PlandoConnection] # consolidated plando connections for the seed group
class TunicWorld(World):
@@ -82,54 +85,66 @@ class TunicWorld(World):
"""
game = "TUNIC"
web = TunicWeb()
author: str = "SilentSR & ScipioWright"
options: TunicOptions
options_dataclass = TunicOptions
settings: ClassVar[TunicSettings]
item_name_groups = item_name_groups
# grass, breakables, fuses, and bells are separated out into their own files
# this makes for easier organization, at the cost of stuff like what's directly below here
location_name_groups = location_name_groups
for group_name, members in grass_location_name_groups.items():
location_name_groups.setdefault(group_name, set()).update(members)
for group_name, members in breakable_location_groups.items():
location_name_groups.setdefault(group_name, set()).update(members)
# for group_name, members in fuse_location_groups.items():
# location_name_groups.setdefault(group_name, set()).update(members)
# for group_name, members in bell_location_groups.items():
# location_name_groups.setdefault(group_name, set()).update(members)
item_name_to_id = item_name_to_id
location_name_to_id = standard_location_name_to_id.copy()
location_name_to_id.update(grass_location_name_to_id)
location_name_to_id.update(breakable_location_name_to_id)
# location_name_to_id.update(fuse_location_name_to_id)
# location_name_to_id.update(bell_location_name_to_id)
player_location_table: Dict[str, int]
ability_unlocks: Dict[str, int]
slot_data_items: List[TunicItem]
tunic_portal_pairs: Dict[str, str]
er_portal_hints: Dict[int, str]
seed_groups: Dict[str, SeedGroup] = {}
used_shop_numbers: Set[int]
er_regions: Dict[str, RegionInfo] # absolutely needed so outlet regions work
player_location_table: dict[str, int]
ability_unlocks: dict[str, int]
slot_data_items: list[TunicItem]
tunic_portal_pairs: dict[str, str]
er_portal_hints: dict[int, str]
seed_groups: dict[str, SeedGroup] = {}
used_shop_numbers: set[int]
er_regions: dict[str, RegionInfo] # absolutely needed so outlet regions work
# for the local_fill option
fill_items: List[TunicItem]
fill_locations: List[Location]
fill_items: list[TunicItem]
fill_locations: list[Location]
backup_locations: list[Location]
amount_to_local_fill: int
# so we only loop the multiworld locations once
# if these are locations instead of their info, it gives a memory leak error
item_link_locations: Dict[int, Dict[str, List[Tuple[int, str]]]] = {}
player_item_link_locations: Dict[str, List[Location]]
item_link_locations: dict[int, dict[str, list[tuple[int, str]]]] = {}
player_item_link_locations: dict[str, list[Location]]
using_ut: bool # so we can check if we're using UT only once
passthrough: Dict[str, Any]
passthrough: dict[str, Any]
ut_can_gen_without_yaml = True # class var that tells it to ignore the player yaml
tracker_world: ClassVar = ut_stuff.tracker_world
def generate_early(self) -> None:
# if you have multiple APWorlds, we want it to fail here instead of at the end of gen
try:
int(self.settings.disable_local_spoiler)
except AttributeError:
raise Exception("You have a TUNIC APWorld in your lib/worlds folder and custom_worlds folder.\n"
"This would cause an error at the end of generation.\n"
"Please remove one of them, most likely the one in lib/worlds.")
# hidden option for me to do multi-slot test gens with random options more easily
if self.options.all_random:
for option_name in (attr.name for attr in fields(TunicOptions)
if attr not in fields(PerGameCommonOptions)):
@@ -145,10 +160,11 @@ class TunicWorld(World):
option.value = self.random.choice(list(option.name_lookup))
check_options(self)
self.er_regions = tunic_er_regions.copy()
# empty plando connections if ER is off
if self.options.plando_connections and not self.options.entrance_rando:
self.options.plando_connections.value = ()
# modify direction and order of plando connections for more consistency later on
if self.options.plando_connections:
def replace_connection(old_cxn: PlandoConnection, new_cxn: PlandoConnection, index: int) -> None:
self.options.plando_connections.value.remove(old_cxn)
@@ -180,6 +196,7 @@ class TunicWorld(World):
self.player_location_table = standard_location_name_to_id.copy()
# setup our defaults for the local_fill option
if self.options.local_fill == -1:
if self.options.grass_randomizer:
if self.options.breakable_shuffle:
@@ -206,9 +223,15 @@ class TunicWorld(World):
self.player_location_table.update({name: num for name, num in breakable_location_name_to_id.items()
if not name.startswith("Purgatory")})
# if self.options.shuffle_fuses:
# self.player_location_table.update(fuse_location_name_to_id)
#
# if self.options.shuffle_bells:
# self.player_location_table.update(bell_location_name_to_id)
@classmethod
def stage_generate_early(cls, multiworld: MultiWorld) -> None:
tunic_worlds: Tuple[TunicWorld] = multiworld.get_game_worlds("TUNIC")
tunic_worlds: tuple[TunicWorld] = multiworld.get_game_worlds("TUNIC")
for tunic in tunic_worlds:
# setting up state combat logic stuff, see has_combat_reqs for its use
# and this is magic so pycharm doesn't like it, unfortunately
@@ -314,10 +337,10 @@ class TunicWorld(World):
return TunicItem(name, itemclass, self.item_name_to_id[name], self.player)
def create_items(self) -> None:
tunic_items: List[TunicItem] = []
tunic_items: list[TunicItem] = []
self.slot_data_items = []
items_to_create: Dict[str, int] = {item: data.quantity_in_item_pool for item, data in item_table.items()}
items_to_create: dict[str, int] = {item: data.quantity_in_item_pool for item, data in item_table.items()}
# Calculate number of hexagons in item pool
if self.options.hexagon_quest:
@@ -377,7 +400,7 @@ class TunicWorld(World):
items_to_create[rgb_hexagon] = 0
# Filler items in the item pool
available_filler: List[str] = [filler for filler in items_to_create if items_to_create[filler] > 0 and
available_filler: list[str] = [filler for filler in items_to_create if items_to_create[filler] > 0 and
item_table[filler].classification == ItemClassification.filler]
# Remove filler to make room for other items
@@ -457,8 +480,8 @@ class TunicWorld(World):
# discard grass from non_local if it's meant to be limited
if self.settings.limit_grass_rando:
self.options.non_local_items.value.discard("Grass")
all_filler: List[TunicItem] = []
non_filler: List[TunicItem] = []
all_filler: list[TunicItem] = []
non_filler: list[TunicItem] = []
for tunic_item in tunic_items:
if (tunic_item.excludable
and tunic_item.name not in self.options.local_items
@@ -477,7 +500,7 @@ class TunicWorld(World):
if self.options.local_fill > 0 and self.multiworld.players > 1:
# we need to reserve a couple locations so that we don't fill up every sphere 1 location
sphere_one_locs = self.multiworld.get_reachable_locations(CollectionState(self.multiworld), self.player)
reserved_locations: Set[Location] = set(self.random.sample(sphere_one_locs, 2))
reserved_locations: set[Location] = set(self.random.sample(sphere_one_locs, 2))
viable_locations = [loc for loc in self.multiworld.get_unfilled_locations(self.player)
if loc not in reserved_locations
and loc.name not in self.options.priority_locations.value]
@@ -487,34 +510,91 @@ class TunicWorld(World):
f"This is likely due to excess plando or priority locations.")
self.random.shuffle(viable_locations)
self.fill_locations = viable_locations[:self.amount_to_local_fill]
self.backup_locations = viable_locations[self.amount_to_local_fill:]
@classmethod
def stage_pre_fill(cls, multiworld: MultiWorld) -> None:
tunic_fill_worlds: List[TunicWorld] = [world for world in multiworld.get_game_worlds("TUNIC")
tunic_fill_worlds: list[TunicWorld] = [world for world in multiworld.get_game_worlds("TUNIC")
if world.options.local_fill.value > 0]
if tunic_fill_worlds and multiworld.players > 1:
grass_fill: List[TunicItem] = []
non_grass_fill: List[TunicItem] = []
grass_fill_locations: List[Location] = []
non_grass_fill_locations: List[Location] = []
grass_fill: list[TunicItem] = []
non_grass_fill: list[TunicItem] = []
grass_fill_locations: list[Location] = []
non_grass_fill_locations: list[Location] = []
backup_grass_locations: list[Location] = []
backup_non_grass_locations: list[Location] = []
for world in tunic_fill_worlds:
if world.options.grass_randomizer:
grass_fill.extend(world.fill_items)
grass_fill_locations.extend(world.fill_locations)
backup_grass_locations.extend(world.backup_locations)
else:
non_grass_fill.extend(world.fill_items)
non_grass_fill_locations.extend(world.fill_locations)
backup_non_grass_locations.extend(world.backup_locations)
multiworld.random.shuffle(grass_fill)
multiworld.random.shuffle(non_grass_fill)
multiworld.random.shuffle(grass_fill_locations)
multiworld.random.shuffle(non_grass_fill_locations)
multiworld.random.shuffle(backup_grass_locations)
multiworld.random.shuffle(backup_non_grass_locations)
# these are slots that filled in TUNIC locations during pre_fill
out_of_spec_worlds = set()
for filler_item in grass_fill:
grass_fill_locations.pop().place_locked_item(filler_item)
loc_to_fill = grass_fill_locations.pop()
try:
loc_to_fill.place_locked_item(filler_item)
except Exception:
out_of_spec_worlds.add(multiworld.worlds[loc_to_fill.item.player].game)
for loc in backup_grass_locations:
if not loc.item:
loc.place_locked_item(filler_item)
break
else:
out_of_spec_worlds.add(multiworld.worlds[loc_to_fill.item.player].game)
else:
raise Exception("TUNIC: Could not fulfill local_filler option. This issue is caused by another "
"world filling TUNIC locations during pre_fill.\n"
"Archipelago does not allow us to place items into the item pool after "
"create_items, so we cannot recover from this issue.\n"
f"This is likely caused by the following world(s): {out_of_spec_worlds}.\n"
f"Please let the world dev(s) for the listed world(s) know that there is an "
f"issue there.\n"
"As a workaround, you can try setting the local_filler option lower for "
"TUNIC slots with Breakable Shuffle or Grass Rando enabled. You may be able to "
"try generating again, as it may not happen every generation.")
for filler_item in non_grass_fill:
non_grass_fill_locations.pop().place_locked_item(filler_item)
loc_to_fill = non_grass_fill_locations.pop()
try:
loc_to_fill.place_locked_item(filler_item)
except Exception:
out_of_spec_worlds.add(multiworld.worlds[loc_to_fill.item.player].game)
for loc in backup_non_grass_locations:
if not loc.item:
loc.place_locked_item(filler_item)
break
else:
out_of_spec_worlds.add(multiworld.worlds[loc_to_fill.item.player].game)
else:
raise Exception("TUNIC: Could not fulfill local_filler option. This issue is caused by another "
"world filling TUNIC locations during pre_fill.\n"
"Archipelago does not allow us to place items into the item pool after "
"create_items, so we cannot recover from this issue.\n"
f"This is likely caused by the following world(s): {out_of_spec_worlds}.\n"
f"Please let the world dev(s) for the listed world(s) know that there is an "
f"issue there.\n"
"As a workaround, you can try setting the local_filler option lower for "
"TUNIC slots with Breakable Shuffle or Grass Rando enabled. You may be able to "
"try generating again, as it may not happen every generation.")
if out_of_spec_worlds:
warning("TUNIC: At least one other world has filled TUNIC locations during pre_fill. This may "
"cause issues for games that rely on placing items in their own world during pre_fill.\n"
f"This is likely being caused by the following world(s): {out_of_spec_worlds}.\n"
"Please let the world dev(s) for the listed world(s) know that there is an issue there.")
def create_regions(self) -> None:
self.tunic_portal_pairs = {}
@@ -522,48 +602,19 @@ class TunicWorld(World):
self.ability_unlocks = randomize_ability_unlocks(self)
# stuff for universal tracker support, can be ignored for standard gen
if self.using_ut:
if self.using_ut and self.options.hexagon_quest_ability_type == "hexagons":
self.ability_unlocks["Pages 24-25 (Prayer)"] = self.passthrough["Hexagon Quest Prayer"]
self.ability_unlocks["Pages 42-43 (Holy Cross)"] = self.passthrough["Hexagon Quest Holy Cross"]
self.ability_unlocks["Pages 52-53 (Icebolt)"] = self.passthrough["Hexagon Quest Icebolt"]
# Most non-standard options use ER regions
if (self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic
or self.options.grass_randomizer or self.options.breakable_shuffle):
portal_pairs = create_er_regions(self)
if self.options.entrance_rando:
# these get interpreted by the game to tell it which entrances to connect
for portal1, portal2 in portal_pairs.items():
self.tunic_portal_pairs[portal1.scene_destination()] = portal2.scene_destination()
else:
# uses the original rules, easier to navigate and reference
for region_name in tunic_regions:
region = Region(region_name, self.player, self.multiworld)
self.multiworld.regions.append(region)
for region_name, exits in tunic_regions.items():
region = self.get_region(region_name)
region.add_exits(exits)
for location_name, location_id in self.player_location_table.items():
region = self.get_region(location_table[location_name].region)
location = TunicLocation(self.player, location_name, location_id, region)
region.locations.append(location)
victory_region = self.get_region("Spirit Arena")
victory_location = TunicLocation(self.player, "The Heir", None, victory_region)
victory_location.place_locked_item(TunicItem("Victory", ItemClassification.progression, None, self.player))
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
victory_region.locations.append(victory_location)
portal_pairs = create_er_regions(self)
if self.options.entrance_rando:
# these get interpreted by the game to tell it which entrances to connect
for portal1, portal2 in portal_pairs.items():
self.tunic_portal_pairs[portal1.scene_destination()] = portal2.scene_destination()
def set_rules(self) -> None:
# same reason as in create_regions
if (self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic
or self.options.grass_randomizer or self.options.breakable_shuffle):
set_er_location_rules(self)
else:
set_region_rules(self)
set_location_rules(self)
set_er_location_rules(self)
def get_filler_item_name(self) -> str:
return self.random.choice(filler_items)
@@ -582,14 +633,14 @@ class TunicWorld(World):
return change
def write_spoiler_header(self, spoiler_handle: TextIO):
if self.options.hexagon_quest and self.options.ability_shuffling\
and self.options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons:
if (self.options.hexagon_quest and self.options.ability_shuffling
and self.options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons):
spoiler_handle.write("\nAbility Unlocks (Hexagon Quest):\n")
for ability in self.ability_unlocks:
# Remove parentheses for better readability
spoiler_handle.write(f'{ability[ability.find("(")+1:ability.find(")")]}: {self.ability_unlocks[ability]} Gold Questagons\n')
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None:
def extend_hint_information(self, hint_data: dict[int, dict[int, str]]) -> None:
if self.options.entrance_rando:
hint_data.update({self.player: {}})
# all state seems to have efficient paths
@@ -626,7 +677,7 @@ class TunicWorld(World):
if hint_text:
hint_data[self.player][location.address] = hint_text
def get_real_location(self, location: Location) -> Tuple[str, int]:
def get_real_location(self, location: Location) -> tuple[str, int]:
# if it's not in a group, it's not in an item link
if location.player not in self.multiworld.groups or not location.item:
return location.name, location.player
@@ -638,8 +689,8 @@ class TunicWorld(World):
f"Using a potentially incorrect location name instead.")
return location.name, location.player
def fill_slot_data(self) -> Dict[str, Any]:
slot_data: Dict[str, Any] = {
def fill_slot_data(self) -> dict[str, Any]:
slot_data: dict[str, Any] = {
"seed": self.random.randint(0, 2147483647),
"start_with_sword": self.options.start_with_sword.value,
"keys_behind_bosses": self.options.keys_behind_bosses.value,
@@ -657,6 +708,8 @@ class TunicWorld(World):
"entrance_rando": int(bool(self.options.entrance_rando.value)),
"decoupled": self.options.decoupled.value if self.options.entrance_rando else 0,
"shuffle_ladders": self.options.shuffle_ladders.value,
# "shuffle_fuses": self.options.shuffle_fuses.value,
# "shuffle_bells": self.options.shuffle_bells.value,
"grass_randomizer": self.options.grass_randomizer.value,
"combat_logic": self.options.combat_logic.value,
"Hexagon Quest Prayer": self.ability_unlocks["Pages 24-25 (Prayer)"],
@@ -674,7 +727,7 @@ class TunicWorld(World):
# checking if groups so that this doesn't run if the player isn't in a group
if groups:
if not self.item_link_locations:
tunic_worlds: Tuple[TunicWorld] = self.multiworld.get_game_worlds("TUNIC")
tunic_worlds: tuple[TunicWorld] = self.multiworld.get_game_worlds("TUNIC")
# figure out our groups and the items in them
for tunic in tunic_worlds:
for group in self.multiworld.get_player_groups(tunic.player):
@@ -710,7 +763,7 @@ class TunicWorld(World):
# for the universal tracker, doesn't get called in standard gen
# docs: https://github.com/FarisTheAncient/Archipelago/blob/tracker/worlds/tracker/docs/re-gen-passthrough.md
@staticmethod
def interpret_slot_data(slot_data: Dict[str, Any]) -> Dict[str, Any]:
def interpret_slot_data(slot_data: dict[str, Any]) -> dict[str, Any]:
# returning slot_data so it regens, giving it back in multiworld.re_gen_passthrough
# we are using re_gen_passthrough over modifying the world here due to complexities with ER
return slot_data

View File

@@ -1,18 +1,18 @@
from enum import IntEnum
from typing import TYPE_CHECKING, NamedTuple
from enum import IntEnum
from BaseClasses import CollectionState, Region
from worlds.generic.Rules import set_rule
from .rules import has_sword, has_melee
from .constants import base_id
from .er_rules import can_shop
from .logic_helpers import has_sword, has_melee
if TYPE_CHECKING:
from . import TunicWorld
# just getting an id that is a decent chunk ahead of the grass ones
breakable_base_id = 509342400 + 8000
class BreakableType(IntEnum):
pot = 1
fire_pot = 2
@@ -341,6 +341,7 @@ breakable_location_table: dict[str, TunicLocationData] = {
}
breakable_base_id = base_id + 8000
breakable_location_name_to_id: dict[str, int] = {name: breakable_base_id + index
for index, name in enumerate(breakable_location_table)}
@@ -358,6 +359,7 @@ loc_group_convert: dict[str, str] = {
"Beneath the Well Main": "Beneath the Well",
"Well Boss": "Dark Tomb Checkpoint",
"Dark Tomb Main": "Dark Tomb",
"Magic Dagger House": "West Garden House",
"Fortress Courtyard Upper": "Fortress Courtyard",
"Fortress Courtyard Upper pot": "Fortress Courtyard",
"Fortress Courtyard west pots": "Fortress Courtyard",
@@ -370,13 +372,16 @@ loc_group_convert: dict[str, str] = {
"Fortress Grave Path westmost pot": "Fortress Grave Path",
"Fortress Grave Path pots": "Fortress Grave Path",
"Dusty": "Fortress Leaf Piles",
"Frog Stairs Upper": "Frog Stairs",
"Frog Stairs Upper": "Frog Stairway",
"Frog's Domain Front": "Frog's Domain",
"Frog's Domain Main": "Frog's Domain",
"Quarry Monastery Entry": "Quarry",
"Quarry Back": "Quarry",
"Lower Quarry": "Quarry",
"Lower Quarry upper pots": "Quarry",
"Even Lower Quarry": "Quarry",
"Monastery Back": "Monastery",
"Cathedral Main": "Cathedral",
}

View File

@@ -1,10 +1,12 @@
from typing import Dict, List, NamedTuple, Tuple, Optional
from enum import IntEnum
from collections import defaultdict
from enum import IntEnum
from typing import NamedTuple
from BaseClasses import CollectionState
from .rules import has_sword, has_melee
from worlds.AutoWorld import LogicMixin
from .logic_helpers import has_sword, has_melee
# the vanilla stats you are expected to have to get through an area, based on where they are in vanilla
class AreaStats(NamedTuple):
@@ -16,12 +18,12 @@ class AreaStats(NamedTuple):
sp_level: int
mp_level: int
potion_count: int
equipment: List[str] = []
equipment: list[str] = []
is_boss: bool = False
# the vanilla upgrades/equipment you would have
area_data: Dict[str, AreaStats] = {
area_data: dict[str, AreaStats] = {
# The upgrade page is right by the Well entrance. Upper Overworld by the chest in the top right might need something
"Overworld": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Stick"]),
"East Forest": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Sword"]),
@@ -52,9 +54,9 @@ area_data: Dict[str, AreaStats] = {
# these are used for caching which areas can currently be reached in state
# Gauntlet does not have exclusively higher stat requirements, so it will be checked separately
boss_areas: List[str] = [name for name, data in area_data.items() if data.is_boss and name != "Gauntlet"]
boss_areas: list[str] = [name for name, data in area_data.items() if data.is_boss and name != "Gauntlet"]
# Swamp does not have exclusively higher stat requirements, so it will be checked separately
non_boss_areas: List[str] = [name for name, data in area_data.items() if not data.is_boss and name != "Swamp"]
non_boss_areas: list[str] = [name for name, data in area_data.items() if not data.is_boss and name != "Swamp"]
class CombatState(IntEnum):
@@ -114,7 +116,7 @@ def has_combat_reqs(area_name: str, state: CollectionState, player: int) -> bool
return met_combat_reqs
def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_data: Optional[AreaStats] = None) -> bool:
def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_data: AreaStats | None = None) -> bool:
data = alt_data or area_data[area_name]
extra_att_needed = 0
extra_def_needed = 0
@@ -303,7 +305,7 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) ->
# returns a tuple of your max attack level, the number of attack offerings
def get_att_level(state: CollectionState, player: int) -> Tuple[int, int]:
def get_att_level(state: CollectionState, player: int) -> tuple[int, int]:
att_offerings = state.count("ATT Offering", player)
att_upgrades = state.count("Hero Relic - ATT", player)
sword_level = state.count("Sword Upgrade", player)
@@ -315,44 +317,44 @@ def get_att_level(state: CollectionState, player: int) -> Tuple[int, int]:
# returns a tuple of your max defense level, the number of defense offerings
def get_def_level(state: CollectionState, player: int) -> Tuple[int, int]:
def get_def_level(state: CollectionState, player: int) -> tuple[int, int]:
def_offerings = state.count("DEF Offering", player)
# defense falls off, can just cap it at 8 for simplicity
return (min(8, 1 + def_offerings
+ state.count_from_list({"Hero Relic - DEF", "Secret Legend", "Phonomath"}, player))
+ state.count_from_list(("Hero Relic - DEF", "Secret Legend", "Phonomath"), player))
+ (2 if state.has("Shield", player) else 0)
+ (2 if state.has("Hero's Laurels", player) else 0),
def_offerings)
# returns a tuple of your max potion level, the number of potion offerings
def get_potion_level(state: CollectionState, player: int) -> Tuple[int, int]:
def get_potion_level(state: CollectionState, player: int) -> tuple[int, int]:
potion_offerings = min(2, state.count("Potion Offering", player))
# your third potion upgrade (from offerings) costs 1,000 money, reasonable to assume you won't do that
return (1 + potion_offerings
+ state.count_from_list({"Hero Relic - POTION", "Just Some Pals", "Spring Falls", "Back To Work"}, player),
+ state.count_from_list(("Hero Relic - POTION", "Just Some Pals", "Spring Falls", "Back To Work"), player),
potion_offerings)
# returns a tuple of your max hp level, the number of hp offerings
def get_hp_level(state: CollectionState, player: int) -> Tuple[int, int]:
def get_hp_level(state: CollectionState, player: int) -> tuple[int, int]:
hp_offerings = state.count("HP Offering", player)
return 1 + hp_offerings + state.count("Hero Relic - HP", player), hp_offerings
# returns a tuple of your max sp level, the number of sp offerings
def get_sp_level(state: CollectionState, player: int) -> Tuple[int, int]:
def get_sp_level(state: CollectionState, player: int) -> tuple[int, int]:
sp_offerings = state.count("SP Offering", player)
return (1 + sp_offerings
+ state.count_from_list({"Hero Relic - SP", "Mr Mayor", "Power Up",
"Regal Weasel", "Forever Friend"}, player),
+ state.count_from_list(("Hero Relic - SP", "Mr Mayor", "Power Up",
"Regal Weasel", "Forever Friend"), player),
sp_offerings)
def get_mp_level(state: CollectionState, player: int) -> Tuple[int, int]:
def get_mp_level(state: CollectionState, player: int) -> tuple[int, int]:
mp_offerings = state.count("MP Offering", player)
return (1 + mp_offerings
+ state.count_from_list({"Hero Relic - MP", "Sacred Geometry", "Vintage", "Dusty"}, player),
+ state.count_from_list(("Hero Relic - MP", "Sacred Geometry", "Vintage", "Dusty"), player),
mp_offerings)
@@ -426,9 +428,9 @@ def calc_def_sp_cost(def_upgrades: int, sp_upgrades: int) -> int:
class TunicState(LogicMixin):
tunic_need_to_reset_combat_from_collect: Dict[int, bool]
tunic_need_to_reset_combat_from_remove: Dict[int, bool]
tunic_area_combat_state: Dict[int, Dict[str, int]]
tunic_need_to_reset_combat_from_collect: dict[int, bool]
tunic_need_to_reset_combat_from_remove: dict[int, bool]
tunic_area_combat_state: dict[int, dict[str, int]]
def init_mixin(self, _):
# the per-player need to reset the combat state when collecting a combat item

51
worlds/tunic/constants.py Normal file
View File

@@ -0,0 +1,51 @@
base_id = 509342400
laurels = "Hero's Laurels"
grapple = "Magic Orb"
ice_dagger = "Magic Dagger"
fire_wand = "Magic Wand"
gun = "Gun"
lantern = "Lantern"
fairies = "Fairy"
coins = "Golden Coin"
prayer = "Pages 24-25 (Prayer)"
holy_cross = "Pages 42-43 (Holy Cross)"
icebolt = "Pages 52-53 (Icebolt)"
shield = "Shield"
key = "Key"
house_key = "Old House Key"
vault_key = "Fortress Vault Key"
mask = "Scavenger Mask"
red_hexagon = "Red Questagon"
green_hexagon = "Green Questagon"
blue_hexagon = "Blue Questagon"
gold_hexagon = "Gold Questagon"
swamp_fuse_1 = "Swamp Fuse 1"
swamp_fuse_2 = "Swamp Fuse 2"
swamp_fuse_3 = "Swamp Fuse 3"
cathedral_elevator_fuse = "Cathedral Elevator Fuse"
quarry_fuse_1 = "Quarry Fuse 1"
quarry_fuse_2 = "Quarry Fuse 2"
ziggurat_miniboss_fuse = "Ziggurat Miniboss Fuse"
ziggurat_teleporter_fuse = "Ziggurat Teleporter Fuse"
fortress_exterior_fuse_1 = "Fortress Exterior Fuse 1"
fortress_exterior_fuse_2 = "Fortress Exterior Fuse 2"
fortress_courtyard_upper_fuse = "Fortress Courtyard Upper Fuse"
fortress_courtyard_lower_fuse = "Fortress Courtyard Fuse"
beneath_the_vault_fuse = "Beneath the Vault Fuse" # event needs to be renamed probably
fortress_candles_fuse = "Fortress Candles Fuse"
fortress_door_left_fuse = "Fortress Door Left Fuse"
fortress_door_right_fuse = "Fortress Door Right Fuse"
west_furnace_fuse = "West Furnace Fuse"
west_garden_fuse = "West Garden Fuse"
atoll_northeast_fuse = "Atoll Northeast Fuse"
atoll_northwest_fuse = "Atoll Northwest Fuse"
atoll_southeast_fuse = "Atoll Southeast Fuse"
atoll_southwest_fuse = "Atoll Southwest Fuse"
library_lab_fuse = "Library Lab Fuse"
# "Quarry - [East] Bombable Wall" is excluded from this list since it has slightly different rules
bomb_walls = ["East Forest - Bombable Wall", "Eastern Vault Fortress - [East Wing] Bombable Wall",
"Overworld - [Central] Bombable Wall", "Overworld - [Southwest] Bombable Wall Near Fountain",
"Quarry - [West] Upper Area Bombable Wall", "Ruined Atoll - [Northwest] Bombable Wall"]

View File

@@ -1,5 +1,5 @@
from typing import Dict, NamedTuple, List, Optional, TYPE_CHECKING
from enum import IntEnum
from typing import NamedTuple, TYPE_CHECKING
if TYPE_CHECKING:
from . import TunicWorld
@@ -36,7 +36,7 @@ class Portal(NamedTuple):
return self.destination + ", " + self.scene() + self.tag
portal_mapping: List[Portal] = [
portal_mapping: list[Portal] = [
Portal(name="Stick House Entrance", region="Overworld",
destination="Sword Cave", tag="_", direction=Direction.north),
Portal(name="Windmill Entrance", region="Overworld",
@@ -535,7 +535,7 @@ portal_mapping: List[Portal] = [
class RegionInfo(NamedTuple):
game_scene: str # the name of the scene in the actual game
dead_end: int = 0 # if a region has only one exit
outlet_region: Optional[str] = None
outlet_region: str | None = None
is_fake_region: bool = False
@@ -553,7 +553,7 @@ class DeadEnd(IntEnum):
# key is the AP region name. "Fake" in region info just means the mod won't receive that info at all
tunic_er_regions: Dict[str, RegionInfo] = {
tunic_er_regions: dict[str, RegionInfo] = {
"Menu": RegionInfo("Fake", dead_end=DeadEnd.all_cats, is_fake_region=True),
"Overworld": RegionInfo("Overworld Redux"), # main overworld, the central area
"Overworld Holy Cross": RegionInfo("Fake", dead_end=DeadEnd.all_cats, is_fake_region=True), # main overworld holy cross checks
@@ -735,6 +735,7 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Rooted Ziggurat Lower Entry": RegionInfo("ziggurat2020_3"), # the vanilla entry point side
"Rooted Ziggurat Lower Front": RegionInfo("ziggurat2020_3"), # the front for combat logic
"Rooted Ziggurat Lower Mid Checkpoint": RegionInfo("ziggurat2020_3"), # the mid-checkpoint before double admin
"Rooted Ziggurat Lower Miniboss Platform": RegionInfo("ziggurat2020_3"), # the double admin platform
"Rooted Ziggurat Lower Back": RegionInfo("ziggurat2020_3"), # the boss side
"Zig Skip Exit": RegionInfo("ziggurat2020_3", dead_end=DeadEnd.special, outlet_region="Rooted Ziggurat Lower Entry", is_fake_region=True), # for use with fixed shop on
"Rooted Ziggurat Portal Room Entrance": RegionInfo("ziggurat2020_3", outlet_region="Rooted Ziggurat Lower Back"), # the door itself on the zig 3 side
@@ -775,7 +776,6 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats, is_fake_region=True),
}
# this is essentially a pared down version of the region connections in rules.py, with some minor differences
# the main purpose of this is to make it so that you can access every region
# most items are excluded from the rules here, since we can assume Archipelago will properly place them
@@ -786,7 +786,7 @@ tunic_er_regions: Dict[str, RegionInfo] = {
# LS# refers to ladder storage difficulties
# LS rules are used for region connections here regardless of whether you have being knocked out of the air in logic
# this is because it just means you can reach the entrances in that region via ladder storage
traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
traversal_requirements: dict[str, dict[str, list[list[str]]]] = {
"Overworld": {
"Overworld Beach":
[],
@@ -801,7 +801,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Overworld Swamp Lower Entry":
[],
"Overworld Special Shop Entry":
[["Hyperdash"], ["LS1"]],
[["LS1"]],
"Overworld Well Entry Area":
[],
"Overworld Ruined Passage Door":
@@ -823,7 +823,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Overworld Tunnel Turret":
[["IG1"], ["LS1"], ["Hyperdash"]],
"Overworld Temple Door":
[["IG2"], ["LS3"], ["Forest Belltower Upper", "Overworld Belltower"]],
[["Bell Shuffle"], ["IG2"], ["LS3"], ["Forest Belltower Upper", "Overworld Belltower"]],
"Overworld Southeast Cross Door":
[],
"Overworld Fountain Cross Door":
@@ -1229,7 +1229,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
},
"West Garden by Portal": {
"West Garden Portal":
[["West Garden South Checkpoint"]],
[["Fuse Shuffle"], ["West Garden South Checkpoint"]],
"West Garden Portal Item":
[["Hyperdash"]],
},
@@ -1468,7 +1468,8 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Eastern Vault Fortress": {
"Eastern Vault Fortress Gold Door":
[["IG2"], ["Fortress Exterior from Overworld", "Beneath the Vault Back", "Fortress Courtyard Upper"]],
[["IG2"], ["Fuse Shuffle"],
["Fortress Exterior from Overworld", "Beneath the Vault Back", "Fortress Courtyard Upper"]],
},
"Eastern Vault Fortress Gold Door": {
"Eastern Vault Fortress":
@@ -1514,7 +1515,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Fortress Arena": {
"Fortress Arena Portal":
[["Fortress Exterior from Overworld", "Beneath the Vault Back", "Eastern Vault Fortress"]],
[["Fuse Shuffle"], ["Fortress Exterior from Overworld", "Beneath the Vault Back", "Eastern Vault Fortress"]],
},
"Fortress Arena Portal": {
"Fortress Arena":
@@ -1547,7 +1548,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Quarry Entry": {
"Quarry Portal":
[["Quarry Connector"]],
[["Fuse Shuffle"], ["Quarry Connector"]],
"Quarry":
[],
"Monastery Rope":
@@ -1593,7 +1594,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Even Lower Quarry":
[],
"Lower Quarry Zig Door":
[["Quarry", "Quarry Connector"], ["IG3"]],
[["Fuse Shuffle"], ["Quarry", "Quarry Connector"], ["IG3"]],
},
"Monastery Rope": {
"Quarry Back":
@@ -1636,13 +1637,19 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
[["Hyperdash"]],
"Rooted Ziggurat Lower Front":
[],
"Rooted Ziggurat Lower Back":
"Rooted Ziggurat Lower Miniboss Platform":
[],
},
"Rooted Ziggurat Lower Miniboss Platform": {
"Rooted Ziggurat Lower Mid Checkpoint":
[],
"Rooted Ziggurat Lower Back":
[]
},
"Rooted Ziggurat Lower Back": {
"Rooted Ziggurat Lower Entry":
[["LS2"]],
"Rooted Ziggurat Lower Mid Checkpoint":
"Rooted Ziggurat Lower Miniboss Platform":
[["Hyperdash"], ["IG1"]],
"Rooted Ziggurat Portal Room Entrance":
[],
@@ -1658,7 +1665,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
},
"Rooted Ziggurat Portal Room": {
"Rooted Ziggurat Portal Room Exit":
[["Rooted Ziggurat Lower Back"]],
[["Fuse Shuffle"], ["Rooted Ziggurat Lower Back"]],
"Rooted Ziggurat Portal":
[],
},
@@ -1742,7 +1749,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Cathedral Main":
[],
},
"Cathedral Gauntlet Checkpoint": {
"Cathedral Gauntlet":
[],
@@ -1762,13 +1768,13 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Far Shore to East Forest Region":
[["Hyperdash"]],
"Far Shore to Quarry Region":
[["Quarry Connector", "Quarry"]],
[["Fuse Shuffle"], ["Quarry Connector", "Quarry"]],
"Far Shore to Library Region":
[["Library Lab"]],
[["Fuse Shuffle"], ["Library Lab"]],
"Far Shore to West Garden Region":
[["West Garden South Checkpoint"]],
[["Fuse Shuffle"], ["West Garden South Checkpoint"]],
"Far Shore to Fortress Region":
[["Fortress Exterior from Overworld", "Beneath the Vault Back", "Eastern Vault Fortress"]],
[["Fuse Shuffle"], ["Fortress Exterior from Overworld", "Beneath the Vault Back", "Eastern Vault Fortress"]],
},
"Far Shore to Spawn Region": {
"Far Shore":

View File

@@ -1,58 +1,32 @@
from typing import Dict, FrozenSet, Tuple, TYPE_CHECKING
from typing import FrozenSet, TYPE_CHECKING
from BaseClasses import Region
from worlds.generic.Rules import set_rule, add_rule, forbid_item
from BaseClasses import Region, CollectionState
from .options import IceGrappling, LadderStorage, CombatLogic
from .rules import (has_ability, has_sword, has_melee, has_ice_grapple_logic, has_lantern, has_mask, can_ladder_storage,
laurels_zip, bomb_walls)
from .er_data import Portal, get_portal_outlet_region
from .ladder_storage_data import ow_ladder_groups, region_ladders, easy_ls, medium_ls, hard_ls
# from .bells import set_bell_location_rules
from .combat_logic import has_combat_reqs
from .constants import *
from .er_data import Portal, get_portal_outlet_region
# from .fuses import set_fuse_location_rules, has_fuses
from .grass import set_grass_location_rules
from .ladder_storage_data import ow_ladder_groups, region_ladders, easy_ls, medium_ls, hard_ls
from .logic_helpers import (has_ability, has_ladder, has_melee, has_sword, has_lantern, has_mask, has_fuses,
can_shop, can_get_past_bushes, laurels_zip, has_ice_grapple_logic, can_ladder_storage)
from .options import IceGrappling, LadderStorage, CombatLogic
if TYPE_CHECKING:
from . import TunicWorld
laurels = "Hero's Laurels"
grapple = "Magic Orb"
ice_dagger = "Magic Dagger"
fire_wand = "Magic Wand"
gun = "Gun"
lantern = "Lantern"
fairies = "Fairy"
coins = "Golden Coin"
prayer = "Pages 24-25 (Prayer)"
holy_cross = "Pages 42-43 (Holy Cross)"
icebolt = "Pages 52-53 (Icebolt)"
key = "Key"
house_key = "Old House Key"
vault_key = "Fortress Vault Key"
mask = "Scavenger Mask"
red_hexagon = "Red Questagon"
green_hexagon = "Green Questagon"
blue_hexagon = "Blue Questagon"
gold_hexagon = "Gold Questagon"
fuses_option = False # replace with options.shuffle_fuses when fuse shuffle is in
bells_option = False # replace with options.shuffle_bells when bell shuffle is in
def has_ladder(ladder: str, state: CollectionState, world: "TunicWorld") -> bool:
return not world.options.shuffle_ladders or state.has(ladder, world.player)
def can_shop(state: CollectionState, world: "TunicWorld") -> bool:
return has_sword(state, world.player) and state.can_reach_region("Shop", world.player)
# for the ones that are not early bushes where ER can screw you over a bit
def can_get_past_bushes(state: CollectionState, world: "TunicWorld") -> bool:
# add in glass cannon + stick for grass rando
return has_sword(state, world.player) or state.has_any((fire_wand, laurels, gun), world.player)
def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_pairs: Dict[Portal, Portal]) -> None:
def set_er_region_rules(world: "TunicWorld", regions: dict[str, Region], portal_pairs: dict[Portal, Portal]) -> None:
player = world.player
options = world.options
# input scene destination tag, returns portal's name and paired portal's outlet region or region
def get_portal_info(portal_sd: str) -> Tuple[str, str]:
def get_portal_info(portal_sd: str) -> tuple[str, str]:
for portal1, portal2 in portal_pairs.items():
if portal1.scene_destination() == portal_sd:
return portal1.name, get_portal_outlet_region(portal2, world)
@@ -61,7 +35,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
raise Exception(f"No matches found in get_portal_info for {portal_sd}")
# input scene destination tag, returns paired portal's name and region
def get_paired_portal(portal_sd: str) -> Tuple[str, str]:
def get_paired_portal(portal_sd: str) -> tuple[str, str]:
for portal1, portal2 in portal_pairs.items():
if portal1.scene_destination() == portal_sd:
return portal2.name, portal2.region
@@ -81,7 +55,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Overworld"].connect(
connecting_region=regions["Overworld Beach"],
rule=lambda state: has_ladder("Ladders in Overworld Town", state, world)
or state.has_any({laurels, grapple}, player))
or state.has_any((laurels, grapple), player))
# regions["Overworld Beach"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: has_ladder("Ladders in Overworld Town", state, world)
@@ -114,14 +88,14 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
rule=lambda state: state.has(laurels, player))
regions["Overworld to Atoll Upper"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: state.has_any({laurels, grapple}, player))
rule=lambda state: state.has_any((laurels, grapple), player))
regions["Overworld"].connect(
connecting_region=regions["Overworld Belltower"],
rule=lambda state: state.has(laurels, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
regions["Overworld Belltower"].connect(
connecting_region=regions["Overworld"])
# regions["Overworld Belltower"].connect(
# connecting_region=regions["Overworld"])
# ice grapple rudeling across rubble, drop bridge, ice grapple rudeling down
regions["Overworld Belltower"].connect(
@@ -146,17 +120,17 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Overworld Ruined Passage Door"],
rule=lambda state: state.has(key, player, 2)
or laurels_zip(state, world))
regions["Overworld Ruined Passage Door"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: laurels_zip(state, world))
# regions["Overworld Ruined Passage Door"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: laurels_zip(state, world))
regions["Overworld"].connect(
connecting_region=regions["After Ruined Passage"],
rule=lambda state: has_ladder("Ladders near Weathervane", state, world)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
regions["After Ruined Passage"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: has_ladder("Ladders near Weathervane", state, world))
# regions["After Ruined Passage"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: has_ladder("Ladders near Weathervane", state, world))
# for the hard ice grapple, get to the chest after the bomb wall, grab a slime, and grapple push down
# you can ice grapple through the bomb wall, so no need for shop logic checking
@@ -165,10 +139,10 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
rule=lambda state: has_ladder("Ladders near Weathervane", state, world)
or state.has(laurels, player)
or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world))
regions["Above Ruined Passage"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: has_ladder("Ladders near Weathervane", state, world)
or state.has(laurels, player))
# regions["Above Ruined Passage"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: has_ladder("Ladders near Weathervane", state, world)
# or state.has(laurels, player))
regions["After Ruined Passage"].connect(
connecting_region=regions["Above Ruined Passage"],
@@ -183,8 +157,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
regions["East Overworld"].connect(
connecting_region=regions["Above Ruined Passage"],
rule=lambda state: has_ladder("Ladders near Weathervane", state, world)
or state.has(laurels, player))
rule=lambda state: has_ladder("Ladders near Weathervane", state, world))
# nmg: ice grapple the slimes, works both ways consistently
regions["East Overworld"].connect(
@@ -198,9 +171,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["East Overworld"],
rule=lambda state: has_ladder("Ladders near Overworld Checkpoint", state, world)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
regions["East Overworld"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: has_ladder("Ladders near Overworld Checkpoint", state, world))
# regions["East Overworld"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: has_ladder("Ladders near Overworld Checkpoint", state, world))
regions["East Overworld"].connect(
connecting_region=regions["Overworld at Patrol Cave"])
@@ -220,9 +193,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Overworld above Patrol Cave"],
rule=lambda state: has_ladder("Ladders near Overworld Checkpoint", state, world)
or state.has(grapple, player))
regions["Overworld above Patrol Cave"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: has_ladder("Ladders near Overworld Checkpoint", state, world))
# regions["Overworld above Patrol Cave"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: has_ladder("Ladders near Overworld Checkpoint", state, world))
regions["East Overworld"].connect(
connecting_region=regions["Overworld above Patrol Cave"],
@@ -243,10 +216,10 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Upper Overworld"].connect(
connecting_region=regions["Overworld above Quarry Entrance"],
rule=lambda state: state.has_any({grapple, laurels}, player))
rule=lambda state: state.has_any((grapple, laurels), player))
regions["Overworld above Quarry Entrance"].connect(
connecting_region=regions["Upper Overworld"],
rule=lambda state: state.has_any({grapple, laurels}, player))
rule=lambda state: state.has_any((grapple, laurels), player))
# ice grapple push guard captain down the ledge
regions["Upper Overworld"].connect(
@@ -267,11 +240,11 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Overworld"].connect(
connecting_region=regions["Overworld after Envoy"],
rule=lambda state: state.has_any({laurels, grapple, gun}, player)
rule=lambda state: state.has_any((laurels, grapple, gun), player)
or state.has("Sword Upgrade", player, 4))
regions["Overworld after Envoy"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: state.has_any({laurels, grapple, gun}, player)
rule=lambda state: state.has_any((laurels, grapple, gun), player)
or state.has("Sword Upgrade", player, 4))
regions["Overworld after Envoy"].connect(
@@ -285,24 +258,24 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Overworld"].connect(
connecting_region=regions["Overworld Quarry Entry"],
rule=lambda state: has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
regions["Overworld Quarry Entry"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: has_ice_grapple_logic(False, IceGrappling.option_easy, state, world))
# regions["Overworld Quarry Entry"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: has_ice_grapple_logic(False, IceGrappling.option_easy, state, world))
regions["Overworld"].connect(
connecting_region=regions["Overworld Swamp Upper Entry"],
rule=lambda state: state.has(laurels, player))
regions["Overworld Swamp Upper Entry"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: state.has(laurels, player))
# regions["Overworld Swamp Upper Entry"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: state.has(laurels, player))
regions["Overworld"].connect(
connecting_region=regions["Overworld Swamp Lower Entry"],
rule=lambda state: has_ladder("Ladder to Swamp", state, world)
or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world))
regions["Overworld Swamp Lower Entry"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: has_ladder("Ladder to Swamp", state, world))
# regions["Overworld Swamp Lower Entry"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: has_ladder("Ladder to Swamp", state, world))
regions["East Overworld"].connect(
connecting_region=regions["Overworld Special Shop Entry"],
@@ -335,33 +308,34 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Overworld Southeast Cross Door"],
rule=lambda state: has_ability(holy_cross, state, world)
or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world))
regions["Overworld Southeast Cross Door"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: has_ability(holy_cross, state, world))
# regions["Overworld Southeast Cross Door"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: has_ability(holy_cross, state, world))
regions["Overworld"].connect(
connecting_region=regions["Overworld Fountain Cross Door"],
rule=lambda state: has_ability(holy_cross, state, world)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
regions["Overworld Fountain Cross Door"].connect(
connecting_region=regions["Overworld"])
# regions["Overworld Fountain Cross Door"].connect(
# connecting_region=regions["Overworld"])
ow_to_town_portal = regions["Overworld"].connect(
connecting_region=regions["Overworld Town Portal"],
rule=lambda state: has_ability(prayer, state, world))
regions["Overworld Town Portal"].connect(
connecting_region=regions["Overworld"])
# regions["Overworld Town Portal"].connect(
# connecting_region=regions["Overworld"])
regions["Overworld"].connect(
connecting_region=regions["Overworld Spawn Portal"],
rule=lambda state: has_ability(prayer, state, world))
regions["Overworld Spawn Portal"].connect(
connecting_region=regions["Overworld"])
# regions["Overworld Spawn Portal"].connect(
# connecting_region=regions["Overworld"])
# nmg: ice grapple through temple door
regions["Overworld"].connect(
connecting_region=regions["Overworld Temple Door"],
rule=lambda state: state.has_all({"Ring Eastern Bell", "Ring Western Bell"}, player)
rule=lambda state: (state.has_all(("Ring Eastern Bell", "Ring Western Bell"), player) and not bells_option)
or (state.has_all(("East Bell", "West Bell"), player) and bells_option)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
regions["Overworld Temple Door"].connect(
@@ -637,7 +611,8 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["West Garden by Portal"])
regions["West Garden by Portal"].connect(
connecting_region=regions["West Garden Portal"],
rule=lambda state: has_ability(prayer, state, world) and state.has("Activate West Garden Fuse", player))
rule=lambda state: has_ability(prayer, state, world)
and has_fuses("Activate West Garden Fuse", state, world))
regions["West Garden by Portal"].connect(
connecting_region=regions["West Garden Portal Item"],
@@ -691,12 +666,16 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
atoll_statue = regions["Ruined Atoll"].connect(
connecting_region=regions["Ruined Atoll Statue"],
rule=lambda state: has_ability(prayer, state, world)
and ((has_ladder("Ladders in South Atoll", state, world)
and state.has_any((laurels, grapple), player)
and (has_sword(state, player) or state.has_any((fire_wand, gun), player)))
# shoot fuse and have the shot hit you mid-LS
or (can_ladder_storage(state, world) and state.has(fire_wand, player)
and options.ladder_storage >= LadderStorage.option_hard)))
and (((((has_ladder("Ladders in South Atoll", state, world)
and state.has_any((laurels, grapple), player)
and (has_sword(state, player) or state.has_any((gun, fire_wand), player)))
# shoot fuse and have the shot hit you mid-LS
or (can_ladder_storage(state, world) and state.has(fire_wand, player)
and options.ladder_storage >= LadderStorage.option_hard))) and not fuses_option)
or (state.has_all((atoll_northwest_fuse, atoll_northeast_fuse, atoll_southwest_fuse, atoll_southeast_fuse), player)
and fuses_option))
)
regions["Ruined Atoll Statue"].connect(
connecting_region=regions["Ruined Atoll"])
@@ -742,7 +721,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Library Exterior by Tree"].connect(
connecting_region=regions["Library Exterior Ladder Region"],
rule=lambda state: state.has_any({grapple, laurels}, player)
rule=lambda state: state.has_any((grapple, laurels), player)
and has_ladder("Ladders in Library", state, world))
regions["Library Exterior Ladder Region"].connect(
connecting_region=regions["Library Exterior by Tree"],
@@ -785,7 +764,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Library Lab Lower"].connect(
connecting_region=regions["Library Lab"],
rule=lambda state: state.has_any({grapple, laurels}, player)
rule=lambda state: state.has_any((grapple, laurels), player)
and has_ladder("Ladders in Library", state, world))
regions["Library Lab"].connect(
connecting_region=regions["Library Lab Lower"],
@@ -802,7 +781,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Library Lab on Portal Pad"].connect(
connecting_region=regions["Library Portal"],
rule=lambda state: has_ability(prayer, state, world))
rule=lambda state: has_ability(prayer, state, world) and has_fuses("Activate Library Fuse", state, world))
regions["Library Portal"].connect(
connecting_region=regions["Library Lab on Portal Pad"])
@@ -823,10 +802,14 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Fortress Exterior near cave"].connect(
connecting_region=regions["Fortress Exterior from Overworld"],
rule=lambda state: state.has(laurels, player))
rule=lambda state: state.has(laurels, player)
or (has_ability(prayer, state, world) and state.has(fortress_exterior_fuse_1, player)
and fuses_option))
regions["Fortress Exterior from Overworld"].connect(
connecting_region=regions["Fortress Exterior near cave"],
rule=lambda state: state.has(laurels, player) or has_ability(prayer, state, world))
rule=lambda state: state.has(laurels, player)
or (has_ability(prayer, state, world) and state.has(fortress_exterior_fuse_1, player)
if fuses_option else has_ability(prayer, state, world)))
# shoot far fire pot, enemy gets aggro'd
regions["Fortress Exterior near cave"].connect(
@@ -889,12 +872,15 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Eastern Vault Fortress"].connect(
connecting_region=regions["Eastern Vault Fortress Gold Door"],
rule=lambda state: state.has_all({"Activate Eastern Vault West Fuses",
"Activate Eastern Vault East Fuse"}, player)
rule=lambda state: (has_fuses("Activate Eastern Vault West Fuses", state, world)
and has_fuses("Activate Eastern Vault East Fuse", state, world))
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
regions["Eastern Vault Fortress Gold Door"].connect(
connecting_region=regions["Eastern Vault Fortress"],
rule=lambda state: has_ice_grapple_logic(False, IceGrappling.option_easy, state, world))
rule=lambda state: has_ice_grapple_logic(False, IceGrappling.option_easy, state, world)
or (has_fuses("Activate Eastern Vault West Fuses", state, world)
and has_fuses("Activate Eastern Vault East Fuse", state, world)
and fuses_option))
fort_grave_entry_to_combat = regions["Fortress Grave Path Entry"].connect(
connecting_region=regions["Fortress Grave Path Combat"])
@@ -925,7 +911,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Fortress Arena"].connect(
connecting_region=regions["Fortress Arena Portal"],
rule=lambda state: state.has("Activate Eastern Vault West Fuses", player))
rule=lambda state: has_ability(prayer, state, world) and has_fuses("Activate Eastern Vault West Fuses", state, world))
regions["Fortress Arena Portal"].connect(
connecting_region=regions["Fortress Arena"])
@@ -939,7 +925,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Quarry Entry"].connect(
connecting_region=regions["Quarry Portal"],
rule=lambda state: state.has("Activate Quarry Fuse", player))
rule=lambda state: has_ability(prayer, state, world) and has_fuses("Activate Quarry Fuse", state, world))
regions["Quarry Portal"].connect(
connecting_region=regions["Quarry Entry"])
@@ -990,7 +976,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Even Lower Quarry Isolated Chest"].connect(
connecting_region=regions["Lower Quarry Zig Door"],
rule=lambda state: state.has("Activate Quarry Fuse", player)
rule=lambda state: has_fuses("Activate Quarry Fuse", state, world)
or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world))
# don't need the mask for this either, please don't complain about not needing a mask here, you know what you did
@@ -1037,21 +1023,26 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
zig_low_mid_to_front = regions["Rooted Ziggurat Lower Mid Checkpoint"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
zig_low_mid_to_back = regions["Rooted Ziggurat Lower Mid Checkpoint"].connect(
connecting_region=regions["Rooted Ziggurat Lower Back"],
rule=lambda state: state.has(laurels, player)
or (has_sword(state, player) and has_ability(prayer, state, world)))
# can ice grapple to the voidlings to get to the double admin fight, still need to pray at the fuse
zig_low_back_to_mid = regions["Rooted Ziggurat Lower Back"].connect(
regions["Rooted Ziggurat Lower Mid Checkpoint"].connect(
connecting_region=regions["Rooted Ziggurat Lower Miniboss Platform"])
zig_low_miniboss_to_mid = regions["Rooted Ziggurat Lower Miniboss Platform"].connect(
connecting_region=regions["Rooted Ziggurat Lower Mid Checkpoint"],
rule=lambda state: (state.has(laurels, player)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
and has_ability(prayer, state, world)
and has_sword(state, player))
rule=lambda state: state.has(ziggurat_miniboss_fuse, player) if fuses_option
else (has_sword(state, player) and has_ability(prayer, state, world)))
# can ice grapple to the voidlings to get to the double admin fight, still need to pray at the fuse
zig_low_miniboss_to_back = regions["Rooted Ziggurat Lower Miniboss Platform"].connect(
connecting_region=regions["Rooted Ziggurat Lower Back"],
rule=lambda state: state.has(laurels, player) or (state.has(ziggurat_miniboss_fuse, player) and fuses_option)
or (has_sword(state, player) and has_ability(prayer, state, world) and not fuses_option))
regions["Rooted Ziggurat Lower Back"].connect(
connecting_region=regions["Rooted Ziggurat Lower Miniboss Platform"],
rule=lambda state: state.has(laurels, player)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world)
or (state.has(ziggurat_miniboss_fuse, player) and fuses_option))
regions["Rooted Ziggurat Lower Back"].connect(
connecting_region=regions["Rooted Ziggurat Portal Room Entrance"],
rule=lambda state: has_ability(prayer, state, world))
rule=lambda state: has_fuses("Activate Ziggurat Fuse", state, world))
regions["Rooted Ziggurat Portal Room Entrance"].connect(
connecting_region=regions["Rooted Ziggurat Lower Back"])
@@ -1059,11 +1050,11 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Rooted Ziggurat Portal Room"])
regions["Rooted Ziggurat Portal Room"].connect(
connecting_region=regions["Rooted Ziggurat Portal"],
rule=lambda state: has_ability(prayer, state, world))
rule=lambda state: has_fuses("Activate Ziggurat Fuse", state, world) and has_ability(prayer, state, world))
regions["Rooted Ziggurat Portal Room"].connect(
connecting_region=regions["Rooted Ziggurat Portal Room Exit"],
rule=lambda state: state.has("Activate Ziggurat Fuse", player))
rule=lambda state: has_fuses("Activate Ziggurat Fuse", state, world))
regions["Rooted Ziggurat Portal Room Exit"].connect(
connecting_region=regions["Rooted Ziggurat Portal Room"])
@@ -1082,19 +1073,21 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
swamp_mid_to_cath = regions["Swamp Mid"].connect(
connecting_region=regions["Swamp to Cathedral Main Entrance Region"],
rule=lambda state: (has_ability(prayer, state, world)
and (has_sword(state, player))
and has_sword(state, player)
and (state.has(laurels, player)
# blam yourself in the face with a wand shot off the fuse
or (can_ladder_storage(state, world) and state.has(fire_wand, player)
and options.ladder_storage >= LadderStorage.option_hard
and (not options.shuffle_ladders
or state.has_any({"Ladders in Overworld Town",
or state.has_any(("Ladders in Overworld Town",
"Ladder to Swamp",
"Ladders near Weathervane"}, player)
"Ladders near Weathervane"), player)
or (state.has("Ladder to Ruined Atoll", player)
and state.can_reach_region("Overworld Beach", player)))))
and (not options.combat_logic
or has_combat_reqs("Swamp", state, player)))
or has_combat_reqs("Swamp", state, player))
and not fuses_option)
or (state.has_all((swamp_fuse_1, swamp_fuse_2, swamp_fuse_3), player) and fuses_option)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
if options.ladder_storage >= LadderStorage.option_hard and options.shuffle_ladders:
@@ -1102,7 +1095,8 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Swamp to Cathedral Main Entrance Region"].connect(
connecting_region=regions["Swamp Mid"],
rule=lambda state: has_ice_grapple_logic(False, IceGrappling.option_easy, state, world))
rule=lambda state: has_ice_grapple_logic(False, IceGrappling.option_easy, state, world)
or (state.has_all((swamp_fuse_1, swamp_fuse_2, swamp_fuse_3), player) and fuses_option))
# grapple push the enemy by the door down, then grapple to it. Really jank
regions["Swamp Mid"].connect(
@@ -1148,7 +1142,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
cath_entry_to_elev = regions["Cathedral Entry"].connect(
connecting_region=regions["Cathedral to Gauntlet"],
rule=lambda state: (has_ability(prayer, state, world)
rule=lambda state: ((state.has(cathedral_elevator_fuse, player) if fuses_option else has_ability(prayer, state, world))
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
or options.entrance_rando) # elevator is always there in ER
regions["Cathedral to Gauntlet"].connect(
@@ -1159,11 +1153,6 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Cathedral Main"].connect(
connecting_region=regions["Cathedral Entry"])
cath_elev_to_main = regions["Cathedral to Gauntlet"].connect(
connecting_region=regions["Cathedral Main"])
regions["Cathedral Main"].connect(
connecting_region=regions["Cathedral to Gauntlet"])
regions["Cathedral Gauntlet Checkpoint"].connect(
connecting_region=regions["Cathedral Gauntlet"])
@@ -1191,25 +1180,25 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Far Shore"].connect(
connecting_region=regions["Far Shore to West Garden Region"],
rule=lambda state: state.has("Activate West Garden Fuse", player))
rule=lambda state: has_fuses("Activate West Garden Fuse", state, world))
regions["Far Shore to West Garden Region"].connect(
connecting_region=regions["Far Shore"])
regions["Far Shore"].connect(
connecting_region=regions["Far Shore to Quarry Region"],
rule=lambda state: state.has("Activate Quarry Fuse", player))
rule=lambda state: has_fuses("Activate Quarry Fuse", state, world))
regions["Far Shore to Quarry Region"].connect(
connecting_region=regions["Far Shore"])
regions["Far Shore"].connect(
connecting_region=regions["Far Shore to Fortress Region"],
rule=lambda state: state.has("Activate Eastern Vault West Fuses", player))
rule=lambda state: has_fuses("Activate Eastern Vault West Fuses", state, world))
regions["Far Shore to Fortress Region"].connect(
connecting_region=regions["Far Shore"])
regions["Far Shore"].connect(
connecting_region=regions["Far Shore to Library Region"],
rule=lambda state: state.has("Activate Library Fuse", player))
rule=lambda state: has_fuses("Activate Library Fuse", state, world))
regions["Far Shore to Library Region"].connect(
connecting_region=regions["Far Shore"])
@@ -1239,7 +1228,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
non_ow_ls_list.extend(hard_ls)
# create the ls elevation regions
ladder_regions: Dict[str, Region] = {}
ladder_regions: dict[str, Region] = {}
for name in ow_ladder_groups.keys():
ladder_regions[name] = Region(name, player, world.multiworld)
@@ -1409,10 +1398,10 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
lambda state: has_combat_reqs("Dark Tomb", state, player))
set_rule(wg_before_to_after_terry,
lambda state: state.has_any({laurels, ice_dagger}, player)
lambda state: state.has_any((laurels, ice_dagger), player)
or has_combat_reqs("West Garden", state, player))
set_rule(wg_after_to_before_terry,
lambda state: state.has_any({laurels, ice_dagger}, player)
lambda state: state.has_any((laurels, ice_dagger), player)
or has_combat_reqs("West Garden", state, player))
set_rule(wg_after_terry_to_west_combat,
@@ -1453,25 +1442,23 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
lambda state: has_combat_reqs("Rooted Ziggurat", state, player))
set_rule(zig_low_mid_to_front,
lambda state: has_combat_reqs("Rooted Ziggurat", state, player))
set_rule(zig_low_mid_to_back,
set_rule(zig_low_miniboss_to_back,
lambda state: state.has(laurels, player)
or (has_ability(prayer, state, world) and has_combat_reqs("Rooted Ziggurat", state, player)))
set_rule(zig_low_back_to_mid,
lambda state: (state.has(laurels, player)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
and has_ability(prayer, state, world)
and has_combat_reqs("Rooted Ziggurat", state, player))
or (state.has(ziggurat_miniboss_fuse, player) if fuses_option
else (has_ability(prayer, state, world) and has_combat_reqs("Rooted Ziggurat", state, player))))
set_rule(zig_low_miniboss_to_mid,
lambda state: state.has(ziggurat_miniboss_fuse, player) if fuses_option
else (has_ability(prayer, state, world) and has_combat_reqs("Rooted Ziggurat", state, player)))
# only activating the fuse requires combat logic
set_rule(cath_entry_to_elev,
lambda state: options.entrance_rando
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or (has_ability(prayer, state, world) and has_combat_reqs("Swamp", state, player)))
or (state.has(cathedral_elevator_fuse, player) if fuses_option
else (has_ability(prayer, state, world) and has_combat_reqs("Swamp", state, player))))
set_rule(cath_entry_to_main,
lambda state: has_combat_reqs("Swamp", state, player))
set_rule(cath_elev_to_main,
lambda state: has_combat_reqs("Swamp", state, player))
# for spots where you can go into and come out of an entrance to reset enemy aggro
if world.options.entrance_rando:
@@ -1543,10 +1530,17 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
def set_er_location_rules(world: "TunicWorld") -> None:
player = world.player
options = world.options
if world.options.grass_randomizer:
if options.grass_randomizer:
set_grass_location_rules(world)
# if options.shuffle_fuses:
# set_fuse_location_rules(world)
#
# if options.shuffle_bells:
# set_bell_location_rules(world)
forbid_item(world.get_location("Secret Gathering Place - 20 Fairy Reward"), fairies, player)
# Ability Shuffle Exclusive Rules
@@ -1557,7 +1551,7 @@ def set_er_location_rules(world: "TunicWorld") -> None:
set_rule(world.get_location("East Forest - Golden Obelisk Holy Cross"),
lambda state: has_ability(holy_cross, state, world))
set_rule(world.get_location("Beneath the Well - [Powered Secret Room] Chest"),
lambda state: state.has("Activate Furnace Fuse", player))
lambda state: has_fuses("Activate Furnace Fuse", state, world))
set_rule(world.get_location("West Garden - [North] Behind Holy Cross Door"),
lambda state: has_ability(holy_cross, state, world))
set_rule(world.get_location("Library Hall - Holy Cross Chest"),
@@ -1583,9 +1577,9 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# Overworld
set_rule(world.get_location("Overworld - [Southwest] Grapple Chest Over Walkway"),
lambda state: state.has_any({grapple, laurels}, player))
lambda state: state.has_any((grapple, laurels), player))
set_rule(world.get_location("Overworld - [Southwest] West Beach Guarded By Turret 2"),
lambda state: state.has_any({grapple, laurels}, player))
lambda state: state.has_any((grapple, laurels), player))
set_rule(world.get_location("Overworld - [Southwest] From West Garden"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("Overworld - [Southeast] Page on Pillar by Swamp"),
@@ -1635,9 +1629,9 @@ def set_er_location_rules(world: "TunicWorld") -> None:
set_rule(world.get_location("East Forest - Lower Grapple Chest"),
lambda state: state.has(grapple, player))
set_rule(world.get_location("East Forest - Lower Dash Chest"),
lambda state: state.has_all({grapple, laurels}, player))
lambda state: state.has_all((grapple, laurels), player))
set_rule(world.get_location("East Forest - Ice Rod Grapple Chest"), lambda state: (
state.has_all({grapple, ice_dagger, fire_wand}, player) and has_ability(icebolt, state, world)))
state.has_all((grapple, ice_dagger, fire_wand), player) and has_ability(icebolt, state, world)))
# Dark Tomb
# added to make combat logic smoother
@@ -1669,11 +1663,11 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# Frog's Domain
set_rule(world.get_location("Frog's Domain - Side Room Grapple Secret"),
lambda state: state.has_any({grapple, laurels}, player))
lambda state: state.has_any((grapple, laurels), player))
set_rule(world.get_location("Frog's Domain - Grapple Above Hot Tub"),
lambda state: state.has_any({grapple, laurels}, player))
lambda state: state.has_any((grapple, laurels), player))
set_rule(world.get_location("Frog's Domain - Escape Chest"),
lambda state: state.has_any({grapple, laurels}, player))
lambda state: state.has_any((grapple, laurels), player))
# Library Lab
set_rule(world.get_location("Library Lab - Page 1"),
@@ -1695,7 +1689,7 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# Beneath the Vault
set_rule(world.get_location("Beneath the Fortress - Bridge"),
lambda state: has_lantern(state, world) and
(has_melee(state, player) or state.has_any((laurels, fire_wand, ice_dagger, gun), player)))
(has_melee(state, player) or state.has_any((laurels, fire_wand, ice_dagger, gun), player)))
# Quarry
set_rule(world.get_location("Quarry - [Central] Above Ladder Dash Chest"),
@@ -1706,9 +1700,10 @@ def set_er_location_rules(world: "TunicWorld") -> None:
set_rule(world.get_location("Rooted Ziggurat Upper - Near Bridge Switch"),
lambda state: has_sword(state, player) or (state.has(fire_wand, player)
and (state.has(laurels, player)
or world.options.entrance_rando)))
or options.entrance_rando)))
set_rule(world.get_location("Rooted Ziggurat Lower - After Guarded Fuse"),
lambda state: has_sword(state, player) and has_ability(prayer, state, world))
lambda state: state.has(ziggurat_miniboss_fuse, player) if fuses_option
else has_sword(state, player) and has_ability(prayer, state, world))
# Bosses
set_rule(world.get_location("Fortress Arena - Siege Engine/Vault Key Pickup"),
@@ -1750,34 +1745,36 @@ def set_er_location_rules(world: "TunicWorld") -> None:
lambda state: state.has(laurels, player))
# Events
set_rule(world.get_location("Eastern Bell"),
lambda state: (has_melee(state, player) or state.has(fire_wand, player)))
set_rule(world.get_location("Western Bell"),
lambda state: (has_melee(state, player) or state.has(fire_wand, player)))
set_rule(world.get_location("Furnace Fuse"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("South and West Fortress Exterior Fuses"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("Upper and Central Fortress Exterior Fuses"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("Beneath the Vault Fuse"),
lambda state: state.has("Activate South and West Fortress Exterior Fuses", player))
set_rule(world.get_location("Eastern Vault West Fuses"),
lambda state: state.has("Activate Beneath the Vault Fuse", player))
set_rule(world.get_location("Eastern Vault East Fuse"),
lambda state: state.has_all({"Activate Upper and Central Fortress Exterior Fuses",
"Activate South and West Fortress Exterior Fuses"}, player))
set_rule(world.get_location("Quarry Connector Fuse"),
lambda state: has_ability(prayer, state, world) and state.has(grapple, player))
set_rule(world.get_location("Quarry Fuse"),
lambda state: state.has("Activate Quarry Connector Fuse", player))
set_rule(world.get_location("Ziggurat Fuse"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("West Garden Fuse"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("Library Fuse"),
lambda state: has_ability(prayer, state, world) and has_ladder("Ladders in Library", state, world))
if not world.options.hexagon_quest:
if not bells_option:
set_rule(world.get_location("Eastern Bell"),
lambda state: (has_melee(state, player) or state.has(fire_wand, player)))
set_rule(world.get_location("Western Bell"),
lambda state: (has_melee(state, player) or state.has(fire_wand, player)))
if not fuses_option:
set_rule(world.get_location("Furnace Fuse"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("South and West Fortress Exterior Fuses"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("Upper and Central Fortress Exterior Fuses"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("Beneath the Vault Fuse"),
lambda state: state.has("Activate South and West Fortress Exterior Fuses", player))
set_rule(world.get_location("Eastern Vault West Fuses"),
lambda state: state.has("Activate Beneath the Vault Fuse", player))
set_rule(world.get_location("Eastern Vault East Fuse"),
lambda state: state.has_all(("Activate Upper and Central Fortress Exterior Fuses",
"Activate South and West Fortress Exterior Fuses"), player))
set_rule(world.get_location("Quarry Connector Fuse"),
lambda state: has_ability(prayer, state, world) and state.has(grapple, player))
set_rule(world.get_location("Quarry Fuse"),
lambda state: state.has("Activate Quarry Connector Fuse", player))
set_rule(world.get_location("Ziggurat Fuse"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("West Garden Fuse"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("Library Fuse"),
lambda state: has_ability(prayer, state, world) and has_ladder("Ladders in Library", state, world))
if not options.hexagon_quest:
set_rule(world.get_location("Place Questagons"),
lambda state: state.has_all((red_hexagon, blue_hexagon, green_hexagon), player))
@@ -1868,7 +1865,7 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# laurels past the enemies, then use the wand or gun to take care of the fairies that chased you
add_rule(world.get_location("West Garden - [West Lowlands] Tree Holy Cross Chest"),
lambda state: state.has_any({fire_wand, "Gun"}, player))
lambda state: state.has_any((fire_wand, gun), player))
combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Faeries", "West Garden")
combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Save Point", "West Garden")
combat_logic_to_loc("West Garden - [West Highlands] Upper Left Walkway", "West Garden")
@@ -1880,13 +1877,14 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# could just do the last two, but this outputs better in the spoiler log
# dagger is maybe viable here, but it's sketchy -- activate ladder switch, save to reset enemies, climb up
combat_logic_to_loc("Upper and Central Fortress Exterior Fuses", "Eastern Vault Fortress")
combat_logic_to_loc("Beneath the Vault Fuse", "Beneath the Vault")
combat_logic_to_loc("Eastern Vault West Fuses", "Eastern Vault Fortress")
if not fuses_option:
combat_logic_to_loc("Upper and Central Fortress Exterior Fuses", "Eastern Vault Fortress")
combat_logic_to_loc("Beneath the Vault Fuse", "Beneath the Vault")
combat_logic_to_loc("Eastern Vault West Fuses", "Eastern Vault Fortress")
# if you come in from the left, you only need to fight small crabs
add_rule(world.get_location("Ruined Atoll - [South] Near Birds"),
lambda state: has_melee(state, player) or state.has_any({laurels, "Gun"}, player))
lambda state: has_melee(state, player) or state.has_any((laurels, gun), player))
# can get this one without fighting if you have laurels
add_rule(world.get_location("Frog's Domain - Above Vault"),
@@ -1898,8 +1896,21 @@ def set_er_location_rules(world: "TunicWorld") -> None:
and (state.has(laurels, player) or world.options.entrance_rando))
or has_combat_reqs("Rooted Ziggurat", state, player))
set_rule(world.get_location("Rooted Ziggurat Lower - After Guarded Fuse"),
lambda state: has_ability(prayer, state, world)
and has_combat_reqs("Rooted Ziggurat", state, player))
lambda state: state.has(ziggurat_miniboss_fuse, player) if fuses_option
else (has_ability(prayer, state, world) and has_combat_reqs("Rooted Ziggurat", state, player)))
if fuses_option:
set_rule(world.get_location("Rooted Ziggurat Lower - [Miniboss] Activate Fuse"),
lambda state: has_ability(prayer, state, world) and has_combat_reqs("Rooted Ziggurat", state, player))
combat_logic_to_loc("Beneath the Fortress - Activate Fuse", "Beneath the Vault")
combat_logic_to_loc("Fortress Courtyard - [Upper] Activate Fuse", "Eastern Vault Fortress")
combat_logic_to_loc("Fortress Courtyard - [Central] Activate Fuse", "Eastern Vault Fortress")
combat_logic_to_loc("Eastern Vault Fortress - [Candle Room] Activate Fuse", "Eastern Vault Fortress")
combat_logic_to_loc("Eastern Vault Fortress - [Left of Door] Activate Fuse", "Eastern Vault Fortress")
combat_logic_to_loc("Eastern Vault Fortress - [Right of Door] Activate Fuse", "Eastern Vault Fortress")
combat_logic_to_loc("Ruined Atoll - [Northwest] Activate Fuse", "Ruined Atoll")
combat_logic_to_loc("Ruined Atoll - [Southwest] Activate Fuse", "Ruined Atoll")
combat_logic_to_loc("Swamp - [Central] Activate Fuse", "Swamp")
# replace the sword rule with this one
combat_logic_to_loc("Swamp - [South Graveyard] 4 Orange Skulls", "Swamp", set_instead=True)

View File

@@ -1,14 +1,16 @@
from typing import Dict, List, Set, Tuple, TYPE_CHECKING
from copy import deepcopy
from random import Random
from typing import TYPE_CHECKING
from BaseClasses import Region, ItemClassification, Item, Location
from .locations import all_locations
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 .breakables import create_breakable_exclusive_regions, set_breakable_location_rules
from Options import PlandoConnection
from .locations import all_locations
from .options import EntranceRando, EntranceLayout
from random import Random
from copy import deepcopy
if TYPE_CHECKING:
from . import TunicWorld
@@ -22,8 +24,8 @@ class TunicERLocation(Location):
game: str = "TUNIC"
def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
regions: Dict[str, Region] = {}
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():
@@ -83,7 +85,7 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
# keys are event names, values are event regions
tunic_events: Dict[str, str] = {
tunic_events: dict[str, str] = {
"Eastern Bell": "Forest Belltower Upper",
"Western Bell": "Overworld Belltower at Bell",
"Furnace Fuse": "Furnace Fuse",
@@ -101,7 +103,7 @@ tunic_events: Dict[str, str] = {
}
def place_event_items(world: "TunicWorld", regions: Dict[str, Region]) -> None:
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)
@@ -111,9 +113,13 @@ def place_event_items(world: "TunicWorld", regions: Dict[str, Region]) -> None:
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))
else:
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)
@@ -135,7 +141,7 @@ def get_shop_num(world: "TunicWorld") -> int:
# 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:
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)
@@ -144,8 +150,8 @@ def create_shop_region(world: "TunicWorld", regions: Dict[str, Region], portal_n
# 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] = {}
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"]]
@@ -182,10 +188,10 @@ def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Por
# 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] = []
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
@@ -194,6 +200,10 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
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)
shuffle_fuses = False
shuffle_bells = False
traversal_reqs = deepcopy(traversal_requirements)
has_laurels = True
waterfall_plando = False
@@ -207,7 +217,7 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
entrance_layout = seed_group["entrance_layout"]
laurels_location = "10_fairies" if seed_group["laurels_at_10_fairies"] is True else False
logic_tricks: Tuple[bool, int, int] = (laurels_zips, ice_grappling, ladder_storage)
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:
@@ -215,8 +225,8 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
# 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)}
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:
@@ -226,10 +236,6 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
return False
return True
# If using Universal Tracker, restore portal_map. Could be cleaner, but it does not matter for UT even a little bit
if world.using_ut:
portal_map = portal_mapping.copy()
# 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
@@ -291,11 +297,12 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
dead_ends.append(shop_portal)
dead_end_direction_tracker[shop_portal.direction] += 1
connected_regions: Set[str] = set()
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)
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
@@ -371,8 +378,8 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
else:
modified_plando_connections = plando_connections
connected_shop_portal1s: Set[int] = set()
connected_shop_portal2s: Set[int] = set()
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
@@ -419,7 +426,15 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
break
else:
if p_entrance.startswith("Shop Portal "):
portal_num = int(p_entrance.split("Shop Portal ")[-1])
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
@@ -452,7 +467,15 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
else:
if not portal2:
if p_exit.startswith("Shop Portal "):
portal_num = int(p_exit.split("Shop Portal ")[-1])
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]:
@@ -510,13 +533,15 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
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)
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:
@@ -599,7 +624,6 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
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"
@@ -633,7 +657,9 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
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):
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":
@@ -682,8 +708,8 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
# 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)
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
@@ -745,7 +771,7 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
# 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:
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(
@@ -757,8 +783,9 @@ def create_randomized_entrances(world: "TunicWorld", portal_pairs: Dict[Portal,
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]) -> Set[str]:
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)
@@ -790,6 +817,12 @@ def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[s
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
@@ -798,13 +831,14 @@ def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[s
# 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)
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_pairs: dict[int, int] = {
Direction.north: Direction.south,
Direction.south: Direction.north,
Direction.east: Direction.west,
@@ -848,9 +882,9 @@ def verify_plando_directions(connection: PlandoConnection) -> bool:
# 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]
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)

30
worlds/tunic/fuses.py Normal file
View File

@@ -0,0 +1,30 @@
from .constants import *
# for fuse locations and reusing event names to simplify er_rules
fuse_activation_reqs: dict[str, list[str]] = {
swamp_fuse_2: [swamp_fuse_1],
swamp_fuse_3: [swamp_fuse_1, swamp_fuse_2],
fortress_exterior_fuse_2: [fortress_exterior_fuse_1],
beneath_the_vault_fuse: [fortress_exterior_fuse_1, fortress_exterior_fuse_2],
fortress_candles_fuse: [fortress_exterior_fuse_1, fortress_exterior_fuse_2, beneath_the_vault_fuse],
fortress_door_left_fuse: [fortress_exterior_fuse_1, fortress_exterior_fuse_2, beneath_the_vault_fuse,
fortress_candles_fuse],
fortress_courtyard_upper_fuse: [fortress_exterior_fuse_1],
fortress_courtyard_lower_fuse: [fortress_exterior_fuse_1, fortress_courtyard_upper_fuse],
fortress_door_right_fuse: [fortress_exterior_fuse_1, fortress_courtyard_upper_fuse, fortress_courtyard_lower_fuse],
quarry_fuse_2: [quarry_fuse_1],
"Activate Furnace Fuse": [west_furnace_fuse],
"Activate South and West Fortress Exterior Fuses": [fortress_exterior_fuse_1, fortress_exterior_fuse_2],
"Activate Upper and Central Fortress Exterior Fuses": [fortress_exterior_fuse_1, fortress_courtyard_upper_fuse,
fortress_courtyard_lower_fuse],
"Activate Beneath the Vault Fuse": [fortress_exterior_fuse_1, fortress_exterior_fuse_2, beneath_the_vault_fuse],
"Activate Eastern Vault West Fuses": [fortress_exterior_fuse_1, fortress_exterior_fuse_2, beneath_the_vault_fuse,
fortress_candles_fuse, fortress_door_left_fuse],
"Activate Eastern Vault East Fuse": [fortress_exterior_fuse_1, fortress_courtyard_upper_fuse,
fortress_courtyard_lower_fuse, fortress_door_right_fuse],
"Activate Quarry Connector Fuse": [quarry_fuse_1],
"Activate Quarry Fuse": [quarry_fuse_1, quarry_fuse_2],
"Activate Ziggurat Fuse": [ziggurat_teleporter_fuse],
"Activate West Garden Fuse": [west_garden_fuse],
"Activate Library Fuse": [library_lab_fuse],
}

View File

@@ -1,8 +1,11 @@
from typing import Dict, NamedTuple, Optional, TYPE_CHECKING, Set
from typing import NamedTuple, TYPE_CHECKING
from BaseClasses import CollectionState
from worlds.generic.Rules import set_rule, add_rule
from .rules import has_sword, has_melee
from .constants import base_id
from .logic_helpers import has_sword, has_melee
if TYPE_CHECKING:
from . import TunicWorld
@@ -10,12 +13,12 @@ if TYPE_CHECKING:
class TunicLocationData(NamedTuple):
region: str
er_region: str # entrance rando region
location_group: Optional[str] = None
location_group: str | None = None
location_base_id = 509342400
grass_location_table: Dict[str, TunicLocationData] = {
# todo: remove region, make all of these regions append grass to the name
# and then set the rules on the region entrances instead of the locations directly
grass_location_table: dict[str, TunicLocationData] = {
"Overworld - Overworld Grass (576) (7.0, 4.0, -223.0)": TunicLocationData("Overworld", "Overworld"),
"Overworld - Overworld Grass (572) (6.0, 4.0, -223.0)": TunicLocationData("Overworld", "Overworld"),
"Overworld - Overworld Grass (574) (7.0, 4.0, -224.0)": TunicLocationData("Overworld", "Overworld"),
@@ -7763,9 +7766,10 @@ excluded_grass_locations = {
"Overworld - East Overworld Bush (64) (56.0, 44.0, -107.0)",
}
grass_location_name_to_id: Dict[str, int] = {name: location_base_id + 302 + index for index, name in enumerate(grass_location_table)}
grass_base_id = base_id + 302
grass_location_name_to_id: dict[str, int] = {name: grass_base_id + index for index, name in enumerate(grass_location_table)}
grass_location_name_groups: Dict[str, Set[str]] = {}
grass_location_name_groups: dict[str, set[str]] = {}
for loc_name, loc_data in grass_location_table.items():
area_name = loc_name.split(" - ", 1)[0]
# adding it to the normal location group and a grass-only one

View File

@@ -1,7 +1,10 @@
from itertools import groupby
from typing import Dict, List, Set, NamedTuple, Optional
from typing import NamedTuple
from BaseClasses import ItemClassification as IC
from .constants import base_id
class TunicItemData(NamedTuple):
classification: IC
@@ -9,12 +12,10 @@ class TunicItemData(NamedTuple):
item_id_offset: int
item_group: str = ""
# classification if combat logic is on
combat_ic: Optional[IC] = None
combat_ic: None | IC = None
item_base_id = 509342400
item_table: Dict[str, TunicItemData] = {
item_table: dict[str, TunicItemData] = {
"Firecracker x2": TunicItemData(IC.filler, 3, 0, "Bombs"),
"Firecracker x3": TunicItemData(IC.filler, 3, 1, "Bombs"),
"Firecracker x4": TunicItemData(IC.filler, 3, 2, "Bombs"),
@@ -175,7 +176,7 @@ item_table: Dict[str, TunicItemData] = {
}
# items to be replaced by fool traps
fool_tiers: List[List[str]] = [
fool_tiers: list[list[str]] = [
[],
["Money x1", "Money x10", "Money x15", "Money x16"],
["Money x1", "Money x10", "Money x15", "Money x16", "Money x20"],
@@ -214,25 +215,25 @@ slot_data_item_names = [
"Gold Questagon",
]
combat_items: List[str] = [name for name, data in item_table.items()
combat_items: list[str] = [name for name, data in item_table.items()
if data.combat_ic and IC.progression in data.combat_ic]
combat_items.extend(["Stick", "Sword", "Sword Upgrade", "Magic Wand", "Hero's Laurels", "Gun"])
item_name_to_id: Dict[str, int] = {name: item_base_id + data.item_id_offset for name, data in item_table.items()}
item_name_to_id: dict[str, int] = {name: base_id + data.item_id_offset for name, data in item_table.items()}
filler_items: List[str] = [name for name, data in item_table.items() if data.classification == IC.filler and name != "Grass"]
filler_items: list[str] = [name for name, data in item_table.items() if data.classification == IC.filler and name != "Grass"]
def get_item_group(item_name: str) -> str:
return item_table[item_name].item_group
item_name_groups: Dict[str, Set[str]] = {
item_name_groups: dict[str, set[str]] = {
group: set(item_names) for group, item_names in groupby(sorted(item_table, key=get_item_group), get_item_group) if group != ""
}
# extra groups for the purpose of aliasing items
extra_groups: Dict[str, Set[str]] = {
extra_groups: dict[str, set[str]] = {
"Laurels": {"Hero's Laurels"},
"Orb": {"Magic Orb"},
"Dagger": {"Magic Dagger"},

View File

@@ -1,15 +1,15 @@
from typing import Dict, List, Set, NamedTuple, Optional
from typing import NamedTuple
# ladders in overworld, since it is the most complex area for ladder storage
class OWLadderInfo(NamedTuple):
ladders: Set[str] # ladders where the top or bottom is at the same elevation
portals: List[str] # portals at the same elevation, only those without doors
regions: List[str] # regions where a melee enemy can hit you out of ladder storage
ladders: set[str] # ladders where the top or bottom is at the same elevation
portals: list[str] # portals at the same elevation, only those without doors
regions: list[str] # regions where a melee enemy can hit you out of ladder storage
# groups for ladders at the same elevation, for use in determing whether you can ls to entrances in diff rulesets
ow_ladder_groups: Dict[str, OWLadderInfo] = {
ow_ladder_groups: dict[str, OWLadderInfo] = {
# lowest elevation
"LS Elev 0": OWLadderInfo({"Ladders in Overworld Town", "Ladder to Ruined Atoll", "Ladder to Swamp"},
["Swamp Redux 2_conduit", "Overworld Cave_", "Atoll Redux_lower", "Maze Room_",
@@ -49,7 +49,7 @@ ow_ladder_groups: Dict[str, OWLadderInfo] = {
# ladders accessible within different regions of overworld, only those that are relevant
# other scenes will just have them hardcoded since this type of structure is not necessary there
region_ladders: Dict[str, Set[str]] = {
region_ladders: dict[str, set[str]] = {
"Overworld": {"Ladders near Weathervane", "Ladders near Overworld Checkpoint", "Ladders near Dark Tomb",
"Ladders in Overworld Town", "Ladder to Swamp", "Ladders in Well"},
"Overworld Beach": {"Ladder to Ruined Atoll"},
@@ -63,11 +63,11 @@ region_ladders: Dict[str, Set[str]] = {
class LadderInfo(NamedTuple):
origin: str # origin region
destination: str # destination portal
ladders_req: Optional[str] = None # ladders required to do this
ladders_req: str | None = None # ladders required to do this
dest_is_region: bool = False # whether it is a region that you are going to
easy_ls: List[LadderInfo] = [
easy_ls: list[LadderInfo] = [
# In the furnace
# Furnace ladder to the fuse entrance
LadderInfo("Furnace Ladder Area", "Furnace, Overworld Redux_gyro_upper_north"),
@@ -128,7 +128,7 @@ easy_ls: List[LadderInfo] = [
]
# if we can gain elevation or get knocked down, add the harder ones
medium_ls: List[LadderInfo] = [
medium_ls: list[LadderInfo] = [
# region-destination versions of easy ls spots
LadderInfo("East Forest", "East Forest Dance Fox Spot", dest_is_region=True),
# fortress courtyard knockdowns are never logically relevant, the fuse requires upper
@@ -169,7 +169,7 @@ medium_ls: List[LadderInfo] = [
LadderInfo("Back of Swamp", "Swamp Redux 2, Overworld Redux_wall"),
]
hard_ls: List[LadderInfo] = [
hard_ls: list[LadderInfo] = [
# lower ladder, go into the waterfall then above the bonfire, up a ramp, then through the right wall
LadderInfo("Beneath the Well Front", "Sewer, Sewer_Boss_", "Ladders in Well"),
LadderInfo("Beneath the Well Front", "Sewer, Overworld Redux_west_aqueduct", "Ladders in Well"),

View File

@@ -1,17 +1,19 @@
from typing import Dict, NamedTuple, Set, Optional, List
from .grass import grass_location_table
from typing import NamedTuple
# from .bells import bell_location_table
from .breakables import breakable_location_table
from .constants import base_id
# from .fuses import fuse_location_table
from .grass import grass_location_table
class TunicLocationData(NamedTuple):
region: str
er_region: str # entrance rando region
location_group: Optional[str] = None
location_group: str | None = None
location_base_id = 509342400
location_table: Dict[str, TunicLocationData] = {
location_table: dict[str, TunicLocationData] = {
"Beneath the Well - [Powered Secret Room] Chest": TunicLocationData("Beneath the Well", "Beneath the Well Back"),
"Beneath the Well - [Entryway] Chest": TunicLocationData("Beneath the Well", "Beneath the Well Main"),
"Beneath the Well - [Third Room] Beneath Platform Chest": TunicLocationData("Beneath the Well", "Beneath the Well Main"),
@@ -243,7 +245,7 @@ location_table: Dict[str, TunicLocationData] = {
"Rooted Ziggurat Lower - Near Corpses": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Entry"),
"Rooted Ziggurat Lower - Spider Ambush": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Entry"),
"Rooted Ziggurat Lower - Left Of Checkpoint Before Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"),
"Rooted Ziggurat Lower - After Guarded Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"),
"Rooted Ziggurat Lower - After Guarded Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Miniboss Platform"),
"Rooted Ziggurat Lower - Guarded By Double Turrets": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - After 2nd Double Turret Chest": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"),
"Rooted Ziggurat Lower - Guarded By Double Turrets 2": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
@@ -307,7 +309,7 @@ location_table: Dict[str, TunicLocationData] = {
"West Garden - [Central Lowlands] Chest Near Shortcut Bridge": TunicLocationData("West Garden", "West Garden after Terry"),
"West Garden - [West Highlands] Upper Left Walkway": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [Central Lowlands] Chest Beneath Save Point": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [Central Highlands] Behind Guard Captain": TunicLocationData("West Garden", "West Garden before Boss"),
"West Garden - [Central Highlands] Behind Guard Captain": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [Central Highlands] After Garden Knight": TunicLocationData("Overworld", "West Garden after Boss", location_group="Bosses"),
"West Garden - [South Highlands] Secret Chest Beneath Fuse": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [East Lowlands] Page Behind Ice Dagger House": TunicLocationData("West Garden", "West Garden Portal Item"),
@@ -316,19 +318,21 @@ location_table: Dict[str, TunicLocationData] = {
"Hero's Grave - Effigy Relic": TunicLocationData("West Garden", "Hero Relic - West Garden"),
}
hexagon_locations: Dict[str, str] = {
hexagon_locations: dict[str, str] = {
"Red Questagon": "Fortress Arena - Siege Engine/Vault Key Pickup",
"Green Questagon": "Librarian - Hexagon Green",
"Blue Questagon": "Rooted Ziggurat Lower - Hexagon Blue",
}
standard_location_name_to_id: Dict[str, int] = {name: location_base_id + index for index, name in enumerate(location_table)}
standard_location_name_to_id: dict[str, int] = {name: base_id + index for index, name in enumerate(location_table)}
all_locations = location_table.copy()
all_locations.update(grass_location_table)
all_locations.update(breakable_location_table)
# all_locations.update(fuse_location_table)
# all_locations.update(bell_location_table)
location_name_groups: Dict[str, Set[str]] = {}
location_name_groups: dict[str, set[str]] = {}
for loc_name, loc_data in location_table.items():
loc_group_name = loc_name.split(" - ", 1)[0]
location_name_groups.setdefault(loc_group_name, set()).add(loc_name)

View File

@@ -0,0 +1,98 @@
from typing import TYPE_CHECKING
from BaseClasses import CollectionState
from .constants import *
from .fuses import fuse_activation_reqs
from .options import HexagonQuestAbilityUnlockType, IceGrappling
if TYPE_CHECKING:
from . import TunicWorld
def randomize_ability_unlocks(world: "TunicWorld") -> dict[str, int]:
options = world.options
abilities = [prayer, holy_cross, icebolt]
ability_requirement = [1, 1, 1]
world.random.shuffle(abilities)
if options.hexagon_quest.value and options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons:
hexagon_goal = options.hexagon_goal.value
# Set ability unlocks to 25, 50, and 75% of goal amount
ability_requirement = [hexagon_goal // 4, hexagon_goal // 2, hexagon_goal * 3 // 4]
if any(req == 0 for req in ability_requirement):
ability_requirement = [1, 2, 3]
return dict(zip(abilities, ability_requirement))
def has_ability(ability: str, state: CollectionState, world: "TunicWorld") -> bool:
options = world.options
ability_unlocks = world.ability_unlocks
if not options.ability_shuffling:
return True
if options.hexagon_quest and options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons:
return state.has(gold_hexagon, world.player, ability_unlocks[ability])
return state.has(ability, world.player)
# a check to see if you can whack things in melee at all
def has_melee(state: CollectionState, player: int) -> bool:
return state.has_any(("Stick", "Sword", "Sword Upgrade"), player)
def has_sword(state: CollectionState, player: int) -> bool:
return state.has("Sword", player) or state.has("Sword Upgrade", player, 2)
def laurels_zip(state: CollectionState, world: "TunicWorld") -> bool:
return world.options.laurels_zips and state.has(laurels, world.player)
def has_ice_grapple_logic(long_range: bool, difficulty: IceGrappling, state: CollectionState, world: "TunicWorld") -> bool:
if world.options.ice_grappling < difficulty:
return False
if not long_range:
return state.has_all((ice_dagger, grapple), world.player)
else:
return state.has_all((ice_dagger, fire_wand, grapple), world.player) and has_ability(icebolt, state, world)
def can_ladder_storage(state: CollectionState, world: "TunicWorld") -> bool:
if not world.options.ladder_storage:
return False
if world.options.ladder_storage_without_items:
return True
return has_melee(state, world.player) or state.has_any((grapple, shield), world.player)
def has_mask(state: CollectionState, world: "TunicWorld") -> bool:
return world.options.maskless or state.has(mask, world.player)
def has_lantern(state: CollectionState, world: "TunicWorld") -> bool:
return world.options.lanternless or state.has(lantern, world.player)
def has_ladder(ladder: str, state: CollectionState, world: "TunicWorld") -> bool:
return not world.options.shuffle_ladders or state.has(ladder, world.player)
def can_shop(state: CollectionState, world: "TunicWorld") -> bool:
return has_sword(state, world.player) and state.can_reach_region("Shop", world.player)
# for the ones that are not early bushes where ER can screw you over a bit
def can_get_past_bushes(state: CollectionState, world: "TunicWorld") -> bool:
# add in glass cannon + stick for grass rando
return has_sword(state, world.player) or state.has_any((fire_wand, laurels, gun), world.player)
def has_fuses(fuse_event: str, state: CollectionState, world: "TunicWorld") -> bool:
player = world.player
fuses_option = False # replace fuses_option with world.options.shuffle_fuses when fuse shuffle is in
if fuses_option:
return state.has_all(fuse_activation_reqs[fuse_event], player)
return state.has(fuse_event, player)

View File

@@ -1,12 +1,13 @@
import logging
from dataclasses import dataclass
from typing import Dict, Any, TYPE_CHECKING
from decimal import Decimal, ROUND_HALF_UP
import logging
from typing import Any, TYPE_CHECKING
from Options import (DefaultOnToggle, Toggle, StartInventoryPool, Choice, Range, TextChoice, PlandoConnections,
PerGameCommonOptions, OptionGroup, Removed, Visibility, NamedRange)
from .er_data import portal_mapping
if TYPE_CHECKING:
from . import TunicWorld
@@ -145,17 +146,6 @@ class EntranceRando(TextChoice):
default = 0
class FixedShop(Toggle):
"""
This option has been superseded by the Entrance Layout option.
If enabled, it will override the Entrance Layout option.
This is kept to keep older yamls working, and will be removed at a later date.
"""
visibility = Visibility.none
internal_name = "fixed_shop"
display_name = "Fewer Shops in Entrance Rando"
class EntranceLayout(Choice):
"""
Decide how the Entrance Randomizer chooses how to pair the entrances.
@@ -219,8 +209,8 @@ class GrassRandomizer(Toggle):
class LocalFill(NamedRange):
"""
Choose the percentage of your filler/trap items that will be kept local or distributed to other TUNIC players with this option enabled.
This option defaults to 95% if you have Grass Randomizer enabled, 40% if you have Breakable Shuffle enabled, 96% if you have both, and 0% otherwise.
If you have Grass Randomizer enabled, this option must be set to 95% or higher to avoid flooding the item pool. The host can remove this restriction by turning off the limit_grass_rando setting in host.yaml.
This option defaults to 95% if you have Grass Randomizer enabled, and to 0% otherwise.
This option ignores items placed in your local_items or non_local_items.
This option does nothing in single player games.
"""
@@ -332,6 +322,14 @@ class LadderStorageWithoutItems(Toggle):
display_name = "Ladder Storage without Items"
class BreakableShuffle(Toggle):
"""
Turns approximately 250 breakable objects in the game into checks.
"""
internal_name = "breakable_shuffle"
display_name = "Breakable Shuffle"
class HiddenAllRandom(Toggle):
"""
Sets all options that can be random to random.
@@ -342,36 +340,9 @@ class HiddenAllRandom(Toggle):
visibility = Visibility.none
class LogicRules(Choice):
"""
This option has been superseded by the individual trick options.
If set to nmg, it will set Ice Grappling to medium and Laurels Zips on.
If set to ur, it will do nmg as well as set Ladder Storage to medium.
It is here to avoid breaking old yamls, and will be removed at a later date.
"""
visibility = Visibility.none
internal_name = "logic_rules"
display_name = "Logic Rules"
option_restricted = 0
option_no_major_glitches = 1
alias_nmg = 1
option_unrestricted = 2
alias_ur = 2
default = 0
class BreakableShuffle(Toggle):
"""
Turns approximately 250 breakable objects in the game into checks.
"""
internal_name = "breakable_shuffle"
display_name = "Breakable Shuffle"
@dataclass
class TunicOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
sword_progression: SwordProgression
start_with_sword: StartWithSword
keys_behind_bosses: KeysBehindBosses
@@ -386,6 +357,8 @@ class TunicOptions(PerGameCommonOptions):
hexagon_quest_ability_type: HexagonQuestAbilityUnlockType
shuffle_ladders: ShuffleLadders
# shuffle_fuses: ShuffleFuses
# shuffle_bells: ShuffleBells
grass_randomizer: GrassRandomizer
breakable_shuffle: BreakableShuffle
local_fill: LocalFill
@@ -393,7 +366,6 @@ class TunicOptions(PerGameCommonOptions):
entrance_rando: EntranceRando
entrance_layout: EntranceLayout
decoupled: Decoupled
plando_connections: TunicPlandoConnections
combat_logic: CombatLogic
lanternless: Lanternless
@@ -402,11 +374,13 @@ class TunicOptions(PerGameCommonOptions):
ice_grappling: IceGrappling
ladder_storage: LadderStorage
ladder_storage_without_items: LadderStorageWithoutItems
plando_connections: TunicPlandoConnections
all_random: HiddenAllRandom
fixed_shop: FixedShop # will be removed at a later date
logic_rules: Removed # fully removed in the direction pairs update
fixed_shop: Removed
logic_rules: Removed
tunic_option_groups = [
@@ -433,7 +407,7 @@ tunic_option_groups = [
]),
]
tunic_option_presets: Dict[str, Dict[str, Any]] = {
tunic_option_presets: dict[str, dict[str, Any]] = {
"Sync": {
"ability_shuffling": True,
},
@@ -460,14 +434,16 @@ tunic_option_presets: Dict[str, Dict[str, Any]] = {
def check_options(world: "TunicWorld"):
options = world.options
if options.hexagon_quest and options.ability_shuffling and options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons:
if (options.hexagon_quest and options.ability_shuffling
and options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons):
total_hexes = get_hexagons_in_pool(world)
min_hexes = 3
if options.keys_behind_bosses:
min_hexes = 15
if total_hexes < min_hexes:
logging.warning(f"TUNIC: Not enough Gold Hexagons in {world.player_name}'s item pool for Hexagon Ability Shuffle with the selected options. Ability Shuffle mode will be switched to Pages.")
logging.warning(f"TUNIC: Not enough Gold Hexagons in {world.player_name}'s item pool for Hexagon Ability "
"Shuffle with the selected options. Ability Shuffle mode will be switched to Pages.")
options.hexagon_quest_ability_type.value = HexagonQuestAbilityUnlockType.option_pages
@@ -475,4 +451,4 @@ def get_hexagons_in_pool(world: "TunicWorld"):
# Calculate number of hexagons in item pool
options = world.options
return min(int((Decimal(100 + options.extra_hexagon_percentage) / 100 * options.hexagon_goal)
.to_integral_value(rounding=ROUND_HALF_UP)), 100)
.to_integral_value(rounding=ROUND_HALF_UP)), 100)

View File

@@ -1,25 +0,0 @@
tunic_regions: dict[str, tuple[str]] = {
"Menu": ("Overworld",),
"Overworld": ("Overworld Holy Cross", "East Forest", "Dark Tomb", "Beneath the Well", "West Garden",
"Ruined Atoll", "Eastern Vault Fortress", "Beneath the Vault", "Quarry Back", "Quarry", "Swamp",
"Spirit Arena"),
"Overworld Holy Cross": tuple(),
"East Forest": tuple(),
"Dark Tomb": ("West Garden",),
"Beneath the Well": tuple(),
"West Garden": tuple(),
"Ruined Atoll": ("Frog's Domain", "Library"),
"Frog's Domain": tuple(),
"Library": tuple(),
"Eastern Vault Fortress": ("Beneath the Vault",),
"Beneath the Vault": ("Eastern Vault Fortress",),
"Quarry Back": ("Quarry", "Monastery"),
"Quarry": ("Monastery", "Lower Quarry"),
"Monastery": ("Monastery Back",),
"Monastery Back": tuple(),
"Lower Quarry": ("Rooted Ziggurat",),
"Rooted Ziggurat": tuple(),
"Swamp": ("Cathedral",),
"Cathedral": tuple(),
"Spirit Arena": tuple()
}

View File

@@ -1,402 +0,0 @@
from typing import Dict, TYPE_CHECKING
from worlds.generic.Rules import set_rule, forbid_item, add_rule
from BaseClasses import CollectionState
from .options import LadderStorage, IceGrappling, HexagonQuestAbilityUnlockType
if TYPE_CHECKING:
from . import TunicWorld
laurels = "Hero's Laurels"
grapple = "Magic Orb"
ice_dagger = "Magic Dagger"
fire_wand = "Magic Wand"
gun = "Gun"
lantern = "Lantern"
fairies = "Fairy"
coins = "Golden Coin"
prayer = "Pages 24-25 (Prayer)"
holy_cross = "Pages 42-43 (Holy Cross)"
icebolt = "Pages 52-53 (Icebolt)"
shield = "Shield"
key = "Key"
house_key = "Old House Key"
vault_key = "Fortress Vault Key"
mask = "Scavenger Mask"
red_hexagon = "Red Questagon"
green_hexagon = "Green Questagon"
blue_hexagon = "Blue Questagon"
gold_hexagon = "Gold Questagon"
# "Quarry - [East] Bombable Wall" is excluded from this list since it has slightly different rules
bomb_walls = ["East Forest - Bombable Wall", "Eastern Vault Fortress - [East Wing] Bombable Wall",
"Overworld - [Central] Bombable Wall", "Overworld - [Southwest] Bombable Wall Near Fountain",
"Quarry - [West] Upper Area Bombable Wall", "Ruined Atoll - [Northwest] Bombable Wall"]
def randomize_ability_unlocks(world: "TunicWorld") -> Dict[str, int]:
random = world.random
options = world.options
abilities = [prayer, holy_cross, icebolt]
ability_requirement = [1, 1, 1]
random.shuffle(abilities)
if options.hexagon_quest.value and options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons:
hexagon_goal = options.hexagon_goal.value
# Set ability unlocks to 25, 50, and 75% of goal amount
ability_requirement = [hexagon_goal // 4, hexagon_goal // 2, hexagon_goal * 3 // 4]
if any(req == 0 for req in ability_requirement):
ability_requirement = [1, 2, 3]
return dict(zip(abilities, ability_requirement))
def has_ability(ability: str, state: CollectionState, world: "TunicWorld") -> bool:
options = world.options
ability_unlocks = world.ability_unlocks
if not options.ability_shuffling:
return True
if options.hexagon_quest and options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons:
return state.has(gold_hexagon, world.player, ability_unlocks[ability])
return state.has(ability, world.player)
# a check to see if you can whack things in melee at all
def has_melee(state: CollectionState, player: int) -> bool:
return state.has_any({"Stick", "Sword", "Sword Upgrade"}, player)
def has_sword(state: CollectionState, player: int) -> bool:
return state.has("Sword", player) or state.has("Sword Upgrade", player, 2)
def laurels_zip(state: CollectionState, world: "TunicWorld") -> bool:
return world.options.laurels_zips and state.has(laurels, world.player)
def has_ice_grapple_logic(long_range: bool, difficulty: IceGrappling, state: CollectionState, world: "TunicWorld") -> bool:
if world.options.ice_grappling < difficulty:
return False
if not long_range:
return state.has_all({ice_dagger, grapple}, world.player)
else:
return state.has_all({ice_dagger, fire_wand, grapple}, world.player) and has_ability(icebolt, state, world)
def can_ladder_storage(state: CollectionState, world: "TunicWorld") -> bool:
if not world.options.ladder_storage:
return False
if world.options.ladder_storage_without_items:
return True
return has_melee(state, world.player) or state.has_any((grapple, shield), world.player)
def has_mask(state: CollectionState, world: "TunicWorld") -> bool:
return world.options.maskless or state.has(mask, world.player)
def has_lantern(state: CollectionState, world: "TunicWorld") -> bool:
return world.options.lanternless or state.has(lantern, world.player)
def set_region_rules(world: "TunicWorld") -> None:
player = world.player
options = world.options
world.get_entrance("Overworld -> Overworld Holy Cross").access_rule = \
lambda state: has_ability(holy_cross, state, world)
world.get_entrance("Overworld -> Beneath the Well").access_rule = \
lambda state: has_melee(state, player) or state.has(fire_wand, player)
world.get_entrance("Overworld -> Dark Tomb").access_rule = \
lambda state: has_lantern(state, world)
# laurels in, ladder storage in through the furnace, or ice grapple down the belltower
world.get_entrance("Overworld -> West Garden").access_rule = \
lambda state: (state.has(laurels, player)
or can_ladder_storage(state, world)
or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world))
world.get_entrance("Overworld -> Eastern Vault Fortress").access_rule = \
lambda state: state.has(laurels, player) \
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world) \
or can_ladder_storage(state, world)
# using laurels or ls to get in is covered by the -> Eastern Vault Fortress rules
world.get_entrance("Overworld -> Beneath the Vault").access_rule = \
lambda state: (has_lantern(state, world) and has_ability(prayer, state, world)
# there's some boxes in the way
and (has_melee(state, player) or state.has_any((gun, grapple, fire_wand), player)))
world.get_entrance("Ruined Atoll -> Library").access_rule = \
lambda state: (state.has_any({grapple, laurels}, player) and has_ability(prayer, state, world)
and (has_sword(state, player) or state.has_any((fire_wand, gun), player)))
world.get_entrance("Overworld -> Quarry").access_rule = \
lambda state: (has_sword(state, player) or state.has(fire_wand, player)) \
and (state.has_any({grapple, laurels, gun}, player) or can_ladder_storage(state, world))
world.get_entrance("Quarry Back -> Quarry").access_rule = \
lambda state: has_sword(state, player) or state.has(fire_wand, player)
world.get_entrance("Quarry Back -> Monastery").access_rule = \
lambda state: state.has(laurels, player)
world.get_entrance("Monastery -> Monastery Back").access_rule = \
lambda state: (has_sword(state, player) or state.has(fire_wand, player)
or laurels_zip(state, world))
world.get_entrance("Quarry -> Lower Quarry").access_rule = \
lambda state: has_mask(state, world)
world.get_entrance("Lower Quarry -> Rooted Ziggurat").access_rule = \
lambda state: state.has(grapple, player) and has_ability(prayer, state, world)
world.get_entrance("Swamp -> Cathedral").access_rule = \
lambda state: (state.has(laurels, player) and has_ability(prayer, state, world) and has_sword(state, player)) \
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
world.get_entrance("Overworld -> Spirit Arena").access_rule = \
lambda state: ((state.has(gold_hexagon, player, options.hexagon_goal.value) if options.hexagon_quest.value
else state.has_all({red_hexagon, green_hexagon, blue_hexagon}, player)
and state.has_group_unique("Hero Relics", player, 6))
and has_ability(prayer, state, world) and has_sword(state, player)
and state.has_any({lantern, laurels}, player))
world.get_region("Quarry").connect(world.get_region("Rooted Ziggurat"),
rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_hard, state, world)
and has_ability(prayer, state, world))
if options.ladder_storage >= LadderStorage.option_medium:
# ls at any ladder in a safe spot in quarry to get to the monastery rope entrance
add_rule(world.get_entrance(entrance_name="Quarry Back -> Monastery"),
rule=lambda state: can_ladder_storage(state, world))
def set_location_rules(world: "TunicWorld") -> None:
player = world.player
forbid_item(world.get_location("Secret Gathering Place - 20 Fairy Reward"), fairies, player)
# Ability Shuffle Exclusive Rules
set_rule(world.get_location("Far Shore - Page Pickup"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("Fortress Courtyard - Chest Near Cave"),
lambda state: has_ability(prayer, state, world)
or state.has(laurels, player)
or can_ladder_storage(state, world)
or (has_ice_grapple_logic(True, IceGrappling.option_easy, state, world)
and has_lantern(state, world)))
set_rule(world.get_location("Fortress Courtyard - Page Near Cave"),
lambda state: has_ability(prayer, state, world) or state.has(laurels, player)
or can_ladder_storage(state, world)
or (has_ice_grapple_logic(True, IceGrappling.option_easy, state, world)
and has_lantern(state, world)))
set_rule(world.get_location("East Forest - Dancing Fox Spirit Holy Cross"),
lambda state: has_ability(holy_cross, state, world))
set_rule(world.get_location("Forest Grave Path - Holy Cross Code by Grave"),
lambda state: has_ability(holy_cross, state, world))
set_rule(world.get_location("East Forest - Golden Obelisk Holy Cross"),
lambda state: has_ability(holy_cross, state, world))
set_rule(world.get_location("Beneath the Well - [Powered Secret Room] Chest"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("West Garden - [North] Behind Holy Cross Door"),
lambda state: has_ability(holy_cross, state, world))
set_rule(world.get_location("Library Hall - Holy Cross Chest"),
lambda state: has_ability(holy_cross, state, world))
set_rule(world.get_location("Eastern Vault Fortress - [West Wing] Candles Holy Cross"),
lambda state: has_ability(holy_cross, state, world))
set_rule(world.get_location("West Garden - [Central Highlands] Holy Cross (Blue Lines)"),
lambda state: has_ability(holy_cross, state, world))
set_rule(world.get_location("Quarry - [Back Entrance] Bushes Holy Cross"),
lambda state: has_ability(holy_cross, state, world))
set_rule(world.get_location("Cathedral - Secret Legend Trophy Chest"),
lambda state: has_ability(holy_cross, state, world))
# Overworld
set_rule(world.get_location("Overworld - [Southwest] Fountain Page"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("Overworld - [Southwest] Grapple Chest Over Walkway"),
lambda state: state.has_any({grapple, laurels}, player))
set_rule(world.get_location("Overworld - [Southwest] West Beach Guarded By Turret 2"),
lambda state: state.has_any({grapple, laurels}, player))
set_rule(world.get_location("Far Shore - Secret Chest"),
lambda state: state.has(laurels, player) and has_ability(prayer, state, world))
set_rule(world.get_location("Overworld - [Southeast] Page on Pillar by Swamp"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("Old House - Normal Chest"),
lambda state: state.has(house_key, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or laurels_zip(state, world))
set_rule(world.get_location("Old House - Holy Cross Chest"),
lambda state: has_ability(holy_cross, state, world) and (
state.has(house_key, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or laurels_zip(state, world)))
set_rule(world.get_location("Old House - Shield Pickup"),
lambda state: state.has(house_key, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or laurels_zip(state, world))
set_rule(world.get_location("Overworld - [Northwest] Page on Pillar by Dark Tomb"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("Overworld - [Southwest] From West Garden"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("Overworld - [West] Chest After Bell"),
lambda state: state.has(laurels, player)
or (has_lantern(state, world) and has_sword(state, player))
or can_ladder_storage(state, world))
set_rule(world.get_location("Overworld - [Northwest] Chest Beneath Quarry Gate"),
lambda state: state.has_any({grapple, laurels}, player))
set_rule(world.get_location("Overworld - [East] Grapple Chest"),
lambda state: state.has(grapple, player))
set_rule(world.get_location("Special Shop - Secret Page Pickup"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("Sealed Temple - Holy Cross Chest"),
lambda state: has_ability(holy_cross, state, world)
and (state.has(laurels, player) or (has_lantern(state, world) and (has_sword(state, player)
or state.has(fire_wand, player)))
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)))
set_rule(world.get_location("Sealed Temple - Page Pickup"),
lambda state: state.has(laurels, player)
or (has_lantern(state, world) and (has_sword(state, player) or state.has(fire_wand, player)))
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
set_rule(world.get_location("West Furnace - Lantern Pickup"),
lambda state: has_melee(state, player) or state.has_any({fire_wand, laurels}, player))
set_rule(world.get_location("Secret Gathering Place - 10 Fairy Reward"),
lambda state: state.has(fairies, player, 10))
set_rule(world.get_location("Secret Gathering Place - 20 Fairy Reward"),
lambda state: state.has(fairies, player, 20))
set_rule(world.get_location("Coins in the Well - 3 Coins"),
lambda state: state.has(coins, player, 3))
set_rule(world.get_location("Coins in the Well - 6 Coins"),
lambda state: state.has(coins, player, 6))
set_rule(world.get_location("Coins in the Well - 10 Coins"),
lambda state: state.has(coins, player, 10))
set_rule(world.get_location("Coins in the Well - 15 Coins"),
lambda state: state.has(coins, player, 15))
# East Forest
set_rule(world.get_location("East Forest - Lower Grapple Chest"),
lambda state: state.has(grapple, player))
set_rule(world.get_location("East Forest - Lower Dash Chest"),
lambda state: state.has_all({grapple, laurels}, player))
set_rule(world.get_location("East Forest - Ice Rod Grapple Chest"),
lambda state: state.has_all({grapple, ice_dagger, fire_wand}, player)
and has_ability(icebolt, state, world))
# West Garden
set_rule(world.get_location("West Garden - [North] Across From Page Pickup"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("West Garden - [West] In Flooded Walkway"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("West Garden - [West Lowlands] Tree Holy Cross Chest"),
lambda state: state.has(laurels, player) and has_ability(holy_cross, state, world))
set_rule(world.get_location("West Garden - [East Lowlands] Page Behind Ice Dagger House"),
lambda state: (state.has(laurels, player) and has_ability(prayer, state, world))
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
set_rule(world.get_location("West Garden - [Central Lowlands] Below Left Walkway"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("West Garden - [Central Highlands] After Garden Knight"),
lambda state: state.has(laurels, player)
or (has_lantern(state, world) and has_sword(state, player))
or can_ladder_storage(state, world))
# Ruined Atoll
set_rule(world.get_location("Ruined Atoll - [West] Near Kevin Block"),
lambda state: state.has(laurels, player))
# ice grapple push a crab through the door
set_rule(world.get_location("Ruined Atoll - [East] Locked Room Lower Chest"),
lambda state: state.has(laurels, player) or state.has(key, player, 2)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
set_rule(world.get_location("Ruined Atoll - [East] Locked Room Upper Chest"),
lambda state: state.has(laurels, player) or state.has(key, player, 2)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
set_rule(world.get_location("Librarian - Hexagon Green"),
lambda state: has_sword(state, player))
# Frog's Domain
set_rule(world.get_location("Frog's Domain - Side Room Grapple Secret"),
lambda state: state.has_any({grapple, laurels}, player))
set_rule(world.get_location("Frog's Domain - Grapple Above Hot Tub"),
lambda state: state.has_any({grapple, laurels}, player))
set_rule(world.get_location("Frog's Domain - Escape Chest"),
lambda state: state.has_any({grapple, laurels}, player))
# Library Lab
set_rule(world.get_location("Library Lab - Page 1"),
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
set_rule(world.get_location("Library Lab - Page 2"),
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
set_rule(world.get_location("Library Lab - Page 3"),
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
# Eastern Vault Fortress
# yes, you can clear the leaves with dagger
# gun isn't included since it can only break one leaf pile at a time, and we don't check how much mana you have
# but really, I expect the player to just throw a bomb at them if they don't have melee
set_rule(world.get_location("Fortress Leaf Piles - Secret Chest"),
lambda state: state.has(laurels, player) and (has_melee(state, player) or state.has(ice_dagger, player)))
set_rule(world.get_location("Fortress Arena - Siege Engine/Vault Key Pickup"),
lambda state: has_sword(state, player)
and (has_ability(prayer, state, world)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)))
set_rule(world.get_location("Fortress Arena - Hexagon Red"),
lambda state: state.has(vault_key, player)
and (has_ability(prayer, state, world)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)))
# Beneath the Vault
set_rule(world.get_location("Beneath the Fortress - Bridge"),
lambda state: has_lantern(state, world) and
(has_melee(state, player) or state.has_any((laurels, fire_wand, ice_dagger, gun), player)))
set_rule(world.get_location("Beneath the Fortress - Obscured Behind Waterfall"),
lambda state: has_melee(state, player) and has_lantern(state, world))
# Quarry
set_rule(world.get_location("Quarry - [Central] Above Ladder Dash Chest"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("Rooted Ziggurat Upper - Near Bridge Switch"),
lambda state: has_sword(state, player) or state.has_all({fire_wand, laurels}, player))
set_rule(world.get_location("Rooted Ziggurat Lower - Hexagon Blue"),
lambda state: has_sword(state, player))
# Swamp
set_rule(world.get_location("Cathedral Gauntlet - Gauntlet Reward"),
lambda state: (state.has(fire_wand, player) and has_sword(state, player))
and (state.has(laurels, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)))
set_rule(world.get_location("Swamp - [Entrance] Above Entryway"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("Swamp - [South Graveyard] Upper Walkway Dash Chest"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("Swamp - [Outside Cathedral] Obscured Behind Memorial"),
lambda state: state.has(laurels, player))
set_rule(world.get_location("Swamp - [South Graveyard] 4 Orange Skulls"),
lambda state: has_sword(state, player))
# Hero's Grave
set_rule(world.get_location("Hero's Grave - Tooth Relic"),
lambda state: state.has(laurels, player) and has_ability(prayer, state, world))
set_rule(world.get_location("Hero's Grave - Mushroom Relic"),
lambda state: state.has(laurels, player) and has_ability(prayer, state, world))
set_rule(world.get_location("Hero's Grave - Ash Relic"),
lambda state: state.has(laurels, player) and has_ability(prayer, state, world))
set_rule(world.get_location("Hero's Grave - Flowers Relic"),
lambda state: state.has(laurels, player) and has_ability(prayer, state, world))
set_rule(world.get_location("Hero's Grave - Effigy Relic"),
lambda state: state.has(laurels, player) and has_ability(prayer, state, world))
set_rule(world.get_location("Hero's Grave - Feathers Relic"),
lambda state: state.has(laurels, player) and has_ability(prayer, state, world))
# Bombable Walls
for location_name in bomb_walls:
# has_sword is there because you can buy bombs in the shop
set_rule(world.get_location(location_name),
lambda state: state.has(gun, player)
or has_sword(state, player)
or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world))
add_rule(world.get_location("Cube Cave - Holy Cross Chest"),
lambda state: state.has(gun, player)
or has_sword(state, player)
or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world))
# can't ice grapple to this one, not enough space
set_rule(world.get_location("Quarry - [East] Bombable Wall"),
lambda state: state.has(gun, player) or has_sword(state, player))
# Shop
set_rule(world.get_location("Shop - Potion 1"),
lambda state: has_sword(state, player))
set_rule(world.get_location("Shop - Potion 2"),
lambda state: has_sword(state, player))
set_rule(world.get_location("Shop - Coin 1"),
lambda state: has_sword(state, player))
set_rule(world.get_location("Shop - Coin 2"),
lambda state: has_sword(state, player))

View File

@@ -5,13 +5,6 @@ from .bases import TunicTestBase
class TestAccess(TunicTestBase):
options = {options.CombatLogic.internal_name: options.CombatLogic.option_off}
# test whether you can get into the temple without laurels
def test_temple_access(self) -> None:
self.collect_all_but(["Hero's Laurels", "Lantern"])
self.assertFalse(self.can_reach_location("Sealed Temple - Page Pickup"))
self.collect_by_name(["Lantern"])
self.assertTrue(self.can_reach_location("Sealed Temple - Page Pickup"))
# test that the wells function properly. Since fairies is written the same way, that should succeed too
def test_wells(self) -> None:
self.collect_all_but(["Golden Coin"])
@@ -50,22 +43,12 @@ class TestHexQuestNoShuffle(TunicTestBase):
self.assertTrue(self.can_reach_location("Fountain Cross Door - Page Pickup"))
class TestNormalGoal(TunicTestBase):
options = {options.HexagonQuest.internal_name: options.HexagonQuest.option_false}
# test that you need the three colored hexes to reach the Heir in standard
def test_normal_goal(self) -> None:
location = ["The Heir"]
items = [["Red Questagon", "Blue Questagon", "Green Questagon"]]
self.assertAccessDependency(location, items)
class TestER(TunicTestBase):
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,
options.CombatLogic.internal_name: options.CombatLogic.option_off,
options.FixedShop.internal_name: options.FixedShop.option_true}
options.EntranceLayout.internal_name: options.EntranceLayout.option_fixed_shop}
def test_overworld_hc_chest(self) -> None:
# test to see that static connections are working properly -- this chest requires holy cross and is in Overworld
@@ -99,7 +82,7 @@ class TestLadderStorage(TunicTestBase):
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,
options.FixedShop.internal_name: options.FixedShop.option_false,
options.EntranceLayout.internal_name: options.EntranceLayout.option_standard,
options.LadderStorage.internal_name: options.LadderStorage.option_hard,
options.LadderStorageWithoutItems.internal_name: options.LadderStorageWithoutItems.option_false,
"plando_connections": [