Files
dockipelago/worlds/tloz_st/__init__.py
Jonathan Tinney 7971961166
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
add schedule I, sonic 1/frontiers/heroes, spirit island
2026-04-02 23:46:36 -07:00

1495 lines
76 KiB
Python

import math
import random
from typing import List, Union, ClassVar, Any, Optional, Tuple
import settings
from BaseClasses import Tutorial, Region, Location, LocationProgressType, Item, ItemClassification, Entrance
from Fill import fill_restrictive, FillError
from Options import Accessibility, OptionError
from worlds.AutoWorld import WebWorld, World
from entrance_rando import randomize_entrances
from .Util import *
from .Options import *
from .data import LOCATIONS_DATA
from .data.Items import ITEMS
from .data.Constants import *
from .data.ModelLookups import all_lookups
from .data.Regions import REGIONS
from .data.LogicPredicates import *
from .data.Entrances import (ENTRANCES, entrance_id_to_region, entrance_id_to_entrance,
location_event_lookup, goal_event_lookup)
from entrance_rando import disconnect_entrance_for_randomization
from .Client import SpiritTracksClient # Unused, but required to register with BizHawkClient
from .Subclasses import EntranceGroups
try: # Backwards compatibility yay
from rule_builder.cached_world import CachedRuleBuilderWorld as WorldParent
from .LogicRB import create_connections
raise ModuleNotFoundError
except ModuleNotFoundError:
# print(f"Spirit Tracks is using legacy logic")
WorldParent = World
from .Logic import create_connections
try:
DEPRIORITIZED_SKIP_BALANCING_FALLBACK = ItemClassification.progression_deprioritized_skip_balancing
DEPRIORITIZED_FALLBACK = ItemClassification.progression_deprioritized
except AttributeError:
DEPRIORITIZED_SKIP_BALANCING_FALLBACK = ItemClassification.progression_skip_balancing
DEPRIORITIZED_FALLBACK = ItemClassification.progression
# Adds a consistent count of items to pool, independent of how many are from locations
def add_items_from_filler(item_pool_dict: dict, filler_item_count: int, item: str, count: int):
count_addable = count-item_pool_dict.setdefault(item,0)
if filler_item_count >= count_addable:
item_pool_dict[item] += count_addable
filler_item_count = filler_item_count - count_addable
else:
item_pool_dict[item] += filler_item_count
filler_item_count = 0
print(f"Ran out of filler items! at {item}")
return item_pool_dict, filler_item_count
class SpiritTracksWeb(WebWorld):
theme = "grassFlowers"
game = "Spirit Tracks"
setup_en = Tutorial(
tutorial_name="Multiworld Setup Guide",
description="A guide to setting up Spirit Tracks for Archipelago on your computer.",
language="English",
file_name="setup.md",
link="setup/en",
authors=["DayKat", "Carrotinator"]
)
tutorials = [setup_en]
option_groups = st_option_groups
class SpiritTracksSettings(settings.Group):
class STTrainSpeed(list[int]):
"""
Train speed for each of the 4 gears, from lowest (reverse) to highest.
defaults are -143, 0, 115, 193
"""
class STTrainInstantStation(str):
"""
Allows entering stations immediately on the stop gear, no matter your speed.
"""
class STTrainSnapSpeed(str):
"""
The train will instantly switch to the new speed when changing gears, no acceleration required.
Does not apply to your stop gear.
"""
train_speed: STTrainSpeed = STTrainSpeed([-143, 0, 115, 193])
train_snap_speed: Union[STTrainSnapSpeed, bool] = True
train_quick_station: Union[STTrainInstantStation, bool] = True
dev_prints = False
class SpiritTracksItem(Item):
game = "Spirit Tracks"
class SpiritTracksWorld(WorldParent):
"""
The Legend of Zelda: Spirit Tracks is the train bound handheld sequel to Phantom Hourglass.
"""
game = "Spirit Tracks"
options_dataclass = SpiritTracksOptions
options: SpiritTracksOptions
settings: ClassVar[SpiritTracksSettings]
required_client_version = (0, 6, 1)
web = SpiritTracksWeb()
topology_present = True
settings_key = "tloz_st_options"
# UT Attributes
location_name_to_id = build_location_name_to_id_dict()
item_name_to_id = build_item_name_to_id_dict()
item_name_groups = ITEM_GROUPS
location_name_groups = LOCATION_GROUPS
origin_region_name = "outset village"
glitches_item_name = "_UT_Glitched_Logic"
ut_can_gen_without_yaml = True
tracker_world = {"map_page_folder": "tracker",
"map_page_maps": "maps/maps.json",
"map_page_locations": ["locations/overworld.json", "entrances/entrances.json"]}
found_entrances_datastorage_key = ["st_checked_entrances_{player}_{team}"]
# Rule builder attributes
item_mapping = ITEM_MAPPING
def __init__(self, multiworld, player):
super().__init__(multiworld, player)
self.pre_fill_items: List[SpiritTracksItem] = []
self.required_dungeons = []
self.non_required_dungeons = []
self.non_required_sections = []
self.dungeon_name_groups = {}
self.locations_to_exclude = set()
self.ut_locations_to_exclude = set()
self.extra_filler_items = []
self.excluded_dungeons = []
self.active_rabbit_locations: list[str] = []
self.rabbit_counts: list[int] = []
self.rabbit_item_dict: dict[str, int] = {}
self.rabbit_realm_items: dict[str, dict[str, int]] = {"Grass": {}, "Snow": {}}
self.item_mapping_collect: dict[str, tuple[str, int]] = {}
self.ut_checked_entrances = set()
self.ut_pairings = {}
self.ut_events = []
self.is_ut = getattr(self.multiworld, "generation_is_fake", False)
self.ut_map_page_hidden_entrances = {"Overview": []}
self.er_placement_state = None
self.valid_entrances: list["Entrance"] = []
self.plando_pairings = {} # int: int pairing
self.tower_pairings = [] # zip object of entrance strings
self.tower_section_lookup = {i:i for i in range(1, 7)} # tower section lookup for logic
self.exclude_tos_5 = 0
self.stamp_items = []
self.stamp_pack_order = []
self.model_lookup = {}
self.sections_included: int = 6
self.required_rupees = 0
self.track_items = []
def generate_early(self):
re_gen_passthrough = getattr(self.multiworld, "re_gen_passthrough", {})
if re_gen_passthrough and self.game in re_gen_passthrough:
# Get the passed through slot data from the real generation
slot_data: dict[str, Any] = re_gen_passthrough[self.game]
print(slot_data)
# slot_options: dict[str, Any] = slot_data.get("options", {})
# Set all your options here instead of getting them from the yaml
for key, value in slot_data.items():
opt = getattr(self.options, key, None)
if opt is not None:
# You can also set .value directly but that won't work if you have OptionSets
setattr(self.options, key, opt.from_any(value))
lookup = build_rabbit_location_id_to_name_dict()
self.active_rabbit_locations = [lookup[i] for i in slot_data["active_rabbit_locs"]]
self.required_dungeons = [self.location_id_to_name[i] for i in slot_data["required_dungeons"]]
self.ut_pairings = slot_data["er_pairings"]
self.tower_section_lookup = {int(k): v for k, v in slot_data["tower_section_lookup"].items()}
self.hide_ut_map_stuff()
self.pick_ut_events()
self.exclude_tos_5 = slot_data["exclude_tos_5"]
self.non_required_sections = [s for s in range(1, 7) if DUNGEON_TO_BOSS_ITEM_LOCATION[f"ToS {s}"] not in self.required_dungeons]
else:
self.required_dungeons = self.pick_required_dungeons()
self.non_required_sections = [s for s in range(1, 7) if DUNGEON_TO_BOSS_ITEM_LOCATION[f"ToS {s}"] not in self.required_dungeons]
if self.options.exclude_sections == "remove":
self.sections_included = 6 - len(self.non_required_sections)
# print(f"Required Dungeons: {self.required_dungeons}")
self.restrict_non_local_items()
self.options.compass_shard_count.value = min(self.options.compass_shard_count.value, self.options.compass_shard_total.value)
self.active_rabbit_locations = self.choose_rabbit_locations()
self.rabbit_item_dict = self.choose_rabbit_items()
self.choose_stamp_items()
# print(f"Rabbit items: {self.rabbit_item_dict}")
self.plando_tos_sections()
# print(f"Tower Sections: {self.tower_section_lookup}")
self.track_items = self.choose_track_items()
# Keyrings don't work with vanilla key locations
if self.options.keysanity.value == 0:
self.options.keyrings.value = min(self.options.keyrings.value, 1)
if self.options.randomize_boss_keys.value <= 0:
self.options.big_keyrings.value = 0
if self.options.keyrings.value > 1 and self.options.big_keyrings and self.options.randomize_boss_keys > 0:
self.options.randomize_boss_keys.value = self.options.keysanity.value
# Starting Train
# Tear conditions
if self.options.randomize_tears.value <= 0: # Vanilla/no tears
self.options.tear_size.value = 0 # force small tears
self.options.tear_sections.value = 0 # force per-section grouping when vanilla
if self.options.tear_sections.value == 0:
self.options.spirit_weapons.value = 0 # no spirit weapons if not progressive/all sections
if self.options.tear_sections.value > 0 and self.options.randomize_tears == "in_own_section":
self.options.randomize_tears.value = 2 # all sections/progressive can't be in own section, make in_tos
if len(self.non_required_sections) == 6 and self.options.exclude_sections and self.options.randomize_tears.value not in [3, 0]:
self.options.spirit_weapons.value = 0
if self.tower_pairings:
tos_summit_section = [EXIT_TO_TOS_SECTION[ex] for en, ex in self.tower_pairings
if en == 'Tower of Spirits Summit Enter Altar'][0]
tos_summit_boss = DUNGEON_TO_BOSS_ITEM_LOCATION[f"ToS {tos_summit_section}"]
else:
tos_summit_boss = "ToS 24F Final Chest"
if all([
self.options.exclude_sections.value,
len(self.non_required_sections) == 5,
self.options.randomize_tears.value in [1, 2],
self.options.spirit_weapons.value,
tos_summit_boss in self.required_dungeons
]):
self.options.spirit_weapons.value = 0
print(f"ToS Summit needs final spirit weapon. Turning off spirit weapons.")
if self.options.starting_train == "random_train":
self.options.starting_train.value = self.random.randint(0, 7)
if "all" in self.options.shopsanity.value:
self.options.shopsanity.value = self.options.shopsanity.valid_keys
# print(f"Shopsanity {self.options.shopsanity.value}")
self.create_item_mappings()
self.non_required_dungeons = [d for d in DUNGEON_NAMES[2:] if DUNGEON_TO_BOSS_ITEM_LOCATION[d] not in self.required_dungeons]
# print(f"non-reqs {self.non_required_dungeons} & {self.non_required_sections}/{self.required_dungeons}")
if 5 in self.non_required_sections and self.options.exclude_sections:
self.exclude_tos_5 = 1
self.required_rupees = self.get_required_rupees()
def plando_tos_sections(self):
"""Plando ToS Shuffle early so we can use the ordering in logic"""
if not self.options.shuffle_tos_sections:
if self.options.exclude_sections == "remove":
sections = [s for s in range(1, 7) if s not in self.non_required_sections]
self.tower_section_lookup = {s: i for i, s in enumerate(sections, start=1)}
self.tower_section_lookup |= {s: 6 for s in self.non_required_sections}
# print(self.tower_section_lookup)
return
# Sophisticated shuffle to avoid loops
entrances = list(ENTRANCE_TO_TOS_ORDER.keys())
exits = list(EXIT_TO_TOS_SECTION.keys()) + ["ToS Summit Lower Exit"]
banned_connections = {"Tower of Spirits Exit Staven": ["ToS 18F Exit"],
"Tower of Spirits Summit Enter Altar": ["ToS Summit Lower Exit"]}
self.random.shuffle(exits)
for entrance in entrances:
if exits[0] in banned_connections.get(entrance, []):
self.tower_pairings.append((entrance, exits.pop(1)))
else:
self.tower_pairings.append((entrance, exits.pop(0)))
if entrance == "Tower of Spirits Exit Staven" and self.tower_pairings[0][1] == "ToS Summit Lower Exit":
banned_connections["Tower of Spirits Summit Enter Altar"].append("ToS 18F Exit")
self.plando_pairings |= {ENTRANCES[e1].id: ENTRANCES[e2].id for e1, e2 in self.tower_pairings}
self.plando_pairings |= {e2: e1 for e1, e2 in self.plando_pairings.items()}
# Get lookup table for logic progressive tear sections
sort_filter = {}
for pair, entr in self.tower_pairings:
if entr in EXIT_TO_TOS_SECTION and pair in ENTRANCE_TO_TOS_ORDER:
sort_filter[EXIT_TO_TOS_SECTION[entr]] = ENTRANCE_TO_TOS_ORDER[pair]
to_sort = [i for i in sort_filter]
old_sort = [i for i in sort_filter]
add_excluded = []
if self.options.exclude_sections == "remove":
required = set(range(1, 7))-set(self.non_required_sections)
add_excluded = self.non_required_sections
for s in self.non_required_sections:
to_sort.remove(s)
to_sort.sort(key=lambda i: sort_filter[i])
self.tower_section_lookup = {section: i + 1 for i, section in enumerate(to_sort)}
self.tower_section_lookup |= {i: 6 for i in add_excluded}
# print(f"Section lookup: {self.tower_section_lookup}")
def get_required_rupees(self):
required_rupees = 0
options = self.options
if "uniques" in options.shopsanity.value: required_rupees += 4500
if "treasure" in options.shopsanity.value: required_rupees += 2400
if "potions" in options.shopsanity.value: required_rupees += 1400
if "shields" in options.shopsanity.value: required_rupees += 610
if "postcards" in options.shopsanity.value: required_rupees += 500
if "ammo" in options.shopsanity.value: required_rupees += 500
if options.randomize_cargo == "vanilla_abstract":
required_rupees += 500
# print(f"Required Rupees {required_rupees}")
return required_rupees
def hide_ut_map_stuff(self):
self.tracker_world["map_page_locations"].append("locations/tos_singles.json")
if not self.options.shuffle_tos_sections:
self.ut_map_page_hidden_entrances["Overview"] += [e.name for e in ENTRANCES.values()
if e.category_group == EntranceGroups.TOS_SECTION]
def pick_ut_events(self):
events = ["EVENT: Give Regal Ring to Linebeck"]
if self.options.goal.value == -1 and self.options.endgame_scope == "enter_dark_realm":
events.append("GOAL: Enter Dark Realm")
else:
events.append(goal_event_lookup[self.options.goal.value])
if self.options.randomize_passengers == "vanilla":
events += ["EVENT: Pick up Alfonzo"]
if self.options.randomize_cargo == "vanilla":
pass
if self.options.randomize_cargo.value > 0:
events += ["EVENT: Bring Ice to Kagoron"]
if self.options.goal == "defeat_malladus" and self.options.dark_realm_access in ["dungeons", "both"]:
if self.options.dungeon_hints or not self.options.require_specific_dungeons:
events += [location_event_lookup[loc] for loc in self.required_dungeons]
else:
events += ["EVENT: Defeat Stagnox", "EVENT: Defeat Fraaz", "EVENT: Defeat Cactops", "EVENT: Defeat Vulcano", "EVENT: Defeat Skeldritch"]
if self.options.tos_dungeon_options == "final_section":
events += ["EVENT: Defeat Staven"]
elif self.options.tos_dungeon_options == "all_sections":
events += ["EVENT: Reach ToS 3F", "EVENT: Reach ToS 7F", "EVENT: Reach ToS 12F", "EVENT: Reach ToS 17F", "EVENT: Defeat Staven", "EVENT: Reach ToS 24F"]
self.ut_events = events
self.ut_map_page_hidden_entrances["Overview"] += [e.name for e in ENTRANCES.values() if
e.category_group == EntranceGroups.EVENT and e.name not in self.ut_events and not e.name.startswith("Unnamed")]
print(f"UT Events: {events} hidden: {self.ut_map_page_hidden_entrances}")
for e in events:
event = ENTRANCES[e]
self.ut_pairings[str(event.id)] = event.vanilla_reciprocal.id
def create_item_mappings(self):
self.item_mapping_collect = {
i: ("Rupees", ITEMS[i].value) for i in ITEM_GROUPS["Rupee Items"]
} | {
r: ("Grass Rabbit", ITEMS[r].value) for r in grass_rabbits[1:]
} | {
r: ("Snow Rabbit", ITEMS[r].value) for r in snow_rabbits[1:]
} | {
r: ("Ocean Rabbit", ITEMS[r].value) for r in ocean_rabbits[1:]
} | {
r: ("Mountain Rabbit", ITEMS[r].value) for r in mountain_rabbits[1:]
} | {
r: ("Sand Rabbit", ITEMS[r].value) for r in sand_rabbits[1:]
} | {
t: ("Treasure", price) for t, price in TREASURE_PRICES.items()
} | {
i: ("Stamp", ITEMS[i].value) for i in ITEM_GROUPS["Stamp Packs"]
} | {
i: ("Stamp", 1) for i in ITEM_GROUPS["Stamps"]
} | {
# Events
"_stamp_stand": ("Stamp", 1)
}
def pick_required_dungeons(self) -> list[str]:
force_require = []
if self.options.goal.value >= 0:
self.options.dark_realm_access.value = 0
force_require = [list(BOSS_LOCATION_TO_EVENT_REGION.keys())[self.options.goal.value]]
if self.options.plando_dungeon_pool:
case_compare = {k.lower(): v for k, v in DUNGEON_TO_BOSS_ITEM_LOCATION.items()}
required_dungeons = list({case_compare[dung.lower()] for dung in self.options.plando_dungeon_pool.value})
else:
required_dungeons = ["Wooded Temple Dungeon Reward", "Blizzard Temple Dungeon Reward",
"Marine Temple Dungeon Reward", "Mountain Temple Dungeon Reward",
"Desert Temple Dungeon Reward"]
implemented_tos = ["ToS 3F Forest Rail Glyph", "ToS 7F Snow Rail Glyph",
"ToS 12F Ocean Rail Glyph", "ToS 17F Fire Rail Glyph",
"ToS 23F Defeat Staven", "ToS 24F Final Chest"]
if self.options.tos_dungeon_options == "final_section":
required_dungeons.append(implemented_tos[-1])
elif self.options.tos_dungeon_options == "all_sections":
required_dungeons += implemented_tos
self.options.dungeons_required.value = min(self.options.dungeons_required.value, len(required_dungeons))
# print(f"Required dungeons: {required_dungeons}")
if not self.options.require_specific_dungeons:
return list(set(required_dungeons + force_require))
required_dungeons = [i for i in required_dungeons if i not in force_require]
self.random.shuffle(required_dungeons)
required_dungeons = force_require + required_dungeons
# print(f"Required dungeons: {required_dungeons}")
required_dungeons = required_dungeons[:self.options.dungeons_required.value]
if self.options.dungeon_hints:
self.options.start_location_hints.value.update(required_dungeons)
return required_dungeons
def restrict_non_local_items(self):
# Restrict non_local_items option in cases where it's incompatible with other options that enforce items
# to be placed locally (e.g. dungeon items with keysanity off)
if not self.options.keysanity == "anywhere":
self.options.non_local_items.value -= self.item_name_groups["Small Keys"]
self.options.non_local_items.value -= self.item_name_groups["Boss Keys"]
def create_location(self, region_name: str, location_name: str, local: bool):
region = self.multiworld.get_region(region_name, self.player)
location = Location(self.player, location_name, self.location_name_to_id[location_name], region)
region.locations.append(location)
if local:
location.item_rule = lambda item: item.player == self.player
def create_regions(self):
# Create regions
for region_name in REGIONS:
region = Region(region_name, self.player, self.multiworld)
self.multiworld.regions.append(region)
# Create locations
for location_name, location_data in LOCATIONS_DATA.items():
if not self.location_is_active(location_name, location_data):
continue
is_local = "local" in location_data and location_data["local"] is True
self.create_location(location_data['region_id'], location_name, is_local)
self.create_events()
self.exclude_locations_automatically()
def create_event(self, region_name, event_item_name):
region = self.get_region(region_name)
location = Location(self.player, region_name + ".event", None, region)
region.locations.append(location)
location.place_locked_item(SpiritTracksItem(event_item_name, ItemClassification.progression, None, self.player))
# When you want multiple copies of the same event in the same region
def create_multiple_events(self, region_name, event_item_name, count):
region = self.get_region(region_name)
locations = [Location(self.player, region_name + f"{i}.event", None, region) for i in range(count)]
for loc in locations:
region.locations.append(loc)
loc.place_locked_item(SpiritTracksItem(event_item_name, ItemClassification.progression, None, self.player))
def location_is_active(self, location_name, location_data):
if not location_data.get("conditional", False) and "rabbit" not in location_data and "dungeon" not in location_data and "tos_section" not in location_data:
return True
if "tos_section" in location_data:
if "stamp" in location_data:
return self.options.randomize_stamps.value in [1, 2, 3]
bk = self.options.randomize_boss_keys.value if location_name.endswith("Boss Key") else True
tears = self.options.randomize_tears.value != -1 if location_data.get("conditional", False) == "tears" else True
return bk and tears and (location_data["tos_section"] not in self.non_required_sections) or self.options.exclude_sections != "remove"
if "dungeon" in location_data:
passengers = self.options.randomize_passengers.value if location_name == "Marine Temple Ferrus Force Gem" else True
stamp = self.options.randomize_stamps.value in [1, 2, 3] if "stamp" in location_data else True
bk = self.options.randomize_boss_keys.value if location_name.endswith("Boss Key") else True
# print(f"Location is active: {location_name}? {location_data['dungeon'] not in self.non_required_dungeons}")
if location_name == "Marine Temple Ferrus Force Gem":
return self.options.randomize_passengers.value
return passengers and stamp and bk and (self.options.exclude_dungeons != "remove" or location_data["dungeon"] not in self.non_required_dungeons)
if "rabbit" in location_data:
return location_name in self.active_rabbit_locations
if "Portal" in location_name:
return self.options.portal_checks
if location_name in LOCATION_GROUPS["Rabbit Rewards"]:
return self.options.rabbitsanity.value
if "minigame" in location_data and self.options.randomize_minigames.value:
if location_name == "Castle Town Take 'em All On Level 3" and "Castle Town Take 'em All On Level 3" in self.required_dungeons:
return True # If plandoed dungeon include
# print(f"Minigame {location_name} {self.options.randomize_minigames.value in location_data['minigame']}")
return self.options.randomize_minigames.value in location_data["minigame"]
if location_name in LOCATION_GROUPS["Stamp Stands"]:
return self.options.randomize_stamps.value in [1, 2, 3]
if location_name in LOCATION_GROUPS["Niko"]:
# If dungeon stamp stands are excluded with vanilla stamps, niko has to give less items
if self.options.exclude_dungeons.value and self.non_required_dungeons and self.options.randomize_stamps.value in [1, 2, 4]:
if len(self.non_required_dungeons) > 5:
return location_name not in ["Outset Niko 15 Stamps Reward", "Outset Niko 20 Stamps Reward"]
return location_name not in ["Outset Niko 20 Stamps Reward"]
return self.options.randomize_stamps.value
if self.options.shopsanity.value and location_name in LOCATION_GROUPS["Shop Locations"]:
if location_name in LOCATION_GROUPS["Shop Restock Locations"]:
if "uniques" in self.options.shopsanity.value:
return False
if location_name == "Beedle Shop Purple Potion":
return "potions" in self.options.shopsanity.value
if location_name == "Snowfall Supermarket Treasure":
return "treasure" in self.options.shopsanity.value
if location_name == "Goron Shop Postcards":
return "postcards" in self.options.shopsanity.value
if location_name in LOCATION_GROUPS["Shop Treasure Locations"]:
return "treasure" in self.options.shopsanity.value
if location_name in LOCATION_GROUPS["Shop Unique Locations"]:
return "uniques" in self.options.shopsanity.value
if location_name in LOCATION_GROUPS["Shop Potion Locations"]:
return "potions" in self.options.shopsanity.value
if location_name in LOCATION_GROUPS["Shop Shield Locations"]:
return "shields" in self.options.shopsanity.value
if location_name in LOCATION_GROUPS["Shop Postcard Locations"]:
return "postcards" in self.options.shopsanity.value
if location_name in LOCATION_GROUPS["Shop Ammo Locations"]:
return "ammo" in self.options.shopsanity.value
if location_name == "Anouki Village Repair Fence":
return self.options.randomize_passengers.value or self.options.randomize_cargo.value
if location_name == "Anouki Village Fence Progress Gift":
return self.options.randomize_passengers.value and self.options.randomize_cargo.value
if self.options.randomize_passengers.value and location_name in LOCATION_GROUPS["Passenger Locations"]:
if "slot_data" in location_data:
for option, values, *args in location_data["slot_data"]:
if option != "randomize_passengers":
continue
values = values if isinstance(values, list) else [values]
if self.options.randomize_passengers.value not in values:
return False
return True
if self.options.randomize_cargo.value and location_name in LOCATION_GROUPS["Cargo Locations"]:
if "slot_data" in location_data:
for option, values, *args in location_data["slot_data"]:
if option != "randomize_cargo":
continue
values = values if isinstance(values, list) else [values]
if self.options.randomize_cargo.value not in values:
return False
return True
if location_name == "Goron Village Get Wagon":
return self.options.randomize_cargo.value
return False
def create_events(self):
if self.options.goal == "defeat_malladus":
self.create_event("malladus goal", "_beaten_game")
if self.options.dark_realm_access in ["dungeons", "both"]:
for loc in self.required_dungeons:
self.create_event(BOSS_LOCATION_TO_EVENT_REGION[loc], "_dungeon_reward")
else:
goal_loc = list(BOSS_LOCATION_TO_EVENT_REGION.keys())[self.options.goal.value]
goal_reg = BOSS_LOCATION_TO_EVENT_REGION[goal_loc]
self.create_event(goal_reg, "_beaten_game")
if self.options.rabbitsanity.value in [3, 4]:
forest_regions = {"forest ocean shortcut rabbit": 1,
"e mayscore rabbits": 2,
"sw trading post rabbit": 1,
"wt rabbit": 1,
"s rabbit haven rabbits": 2,
"nr rabbit haven rabbit": 1,
"forest realm rabbits": 2}
snow_regions = {"snow realm blizzard rabbits": 2,
"snow realm early blizzard rabbits": 3,
"blizzard temple tracks rabbits": 1,
"snow realm rabbits": 1,
"snowdrift station rabbit": 1,
"icyspring rabbits": 2}
ocean_regions = {"ocean rabbits": 6,
"las rabbit": 1,
"ocean portal rabbits": 1,
"ocean source rabbits": 1,
"pirate rabbit": 1}
mountain_regions = {"fire realm rabbits": 2,
"mountain rabbits": 4,
"fire source rabbits": 1,
"disorientation rabbits": 1,
"eote rabbits": 1,
"s mountain temple rabbit": 1}
sand_regions = {"sand realm rabbits": 4,
"sand restoration rabbits": 5,
"sand connection rabbit": 1}
[self.create_multiple_events(reg, f"_caught_{realm}_rabbits", count)
for regions, realm in zip([forest_regions, snow_regions, ocean_regions, mountain_regions, sand_regions], ["grass", "snow", "ocean", "mountain", "sand"])
for reg, count in regions.items()]
if self.options.randomize_stamps.value in [1, 4]:
excluded_dungeons = self.non_required_dungeons if self.options.exclude_dungeons else []
[self.create_event(LOCATIONS_DATA[loc]["region_id"], "_stamp_stand") for loc in LOCATION_GROUPS["Stamp Stands"] if LOCATIONS_DATA[loc].get("dungeon") not in excluded_dungeons]
# Create rupee farming events
rupee_farming_regions = ["mayscore whip chest", "mayscore leaves",
"hyrule castle sword minigame", "pirate hideout minigame",
"gtr"]
[self.create_event(reg, "_rupee_farming_spot") for reg in rupee_farming_regions]
# Passenger Events
if self.options.randomize_passengers == "vanilla":
self.create_event("pick up bridge worker", "_kenzo_1")
self.create_event("trading post pick up kenzo", "_kenzo_2")
self.create_event("av noko", "_noko")
self.create_event("castle town mona", "_mona")
self.create_event("outset joe", "_joe")
self.create_event("alfonzo event", "_picked_up_alfonzo")
self.create_event("mayscore dovok", "_dovok")
self.create_event("pv carben", "_carben")
self.create_event("pirate wadatsumi", "_wadatsumi")
self.create_event("av kofu", "_kofu")
self.create_event("pick up gorons", "_goron")
self.create_event("snow realm ferrus", "_ferrus_1")
self.create_event("fire realm ferrus", "_ferrus_2")
self.create_event("oct ferrus", "_ferrus_3")
self.create_event("icyspring", "_ferrus_backup")
if self.options.randomize_cargo == "vanilla":
self.create_event("mayscore lumber", "_buy_lumber")
self.create_event("icyspring ice", "_buy_ice")
self.create_event("castle town buy cuccos", "_buy_cuccos")
self.create_event("papuzia buy cargo", "_buy_fish")
self.create_event("dark ore mine ore", "_buy_ore")
self.create_event("goron steel", "_buy_steel")
# UT Events
# self.create_event("alfonzo event", "_picked_up_alfonzo")
self.create_event("linebeck event", "_can_sell_treasure")
self.create_event("goron ice event", "_goron_ice") # Used for GTR
def exclude_locations_automatically(self):
locations_to_exclude = set()
self.ut_locations_to_exclude = locations_to_exclude.copy()
self.locations_to_exclude = locations_to_exclude
if self.options.exclude_dungeons == "exclude":
self.locations_to_exclude.update([loc for loc, d in LOCATIONS_DATA.items() if "dungeon" in d and d["dungeon"] in self.non_required_dungeons])
self.locations_to_exclude -= {"Marine Temple Ferrus Force Gem"}
if self.options.exclude_sections == "exclude":
self.locations_to_exclude.update([loc for loc, d in LOCATIONS_DATA.items() if "tos_section" in d and d["tos_section"] in self.non_required_sections])
if self.options.randomize_passengers == "vanilla_abstract":
self.locations_to_exclude.update(["Mayscore Pick Up Wood",
"Mayscore Pick Up Mash",
"Mayscore Pick Up Yamahiko",
"Mayscore Pick Up Morris",
"Castle Town Pick Up Teacher"])
# Take item off goal + post goal location
if self.options.goal.value >= 0:
current_goal = list(BOSS_LOCATION_TO_EVENT_REGION.keys())[self.options.goal.value]
self.locations_to_exclude.add(current_goal)
for loc in BOSS_LOCATION_TO_POST_LOCATIONS.get(current_goal, []):
self.locations_to_exclude.add(loc)
for name in self.locations_to_exclude:
try:
self.multiworld.get_location(name, self.player).progress_type = LocationProgressType.EXCLUDED
except KeyError: # Would it be more efficient to check if location is in active locations first?
pass
def set_rules(self):
create_connections(self, self.player, self.origin_region_name, self.options)
def create_item(self, name: str) -> SpiritTracksItem:
classification = ITEMS[name].classification
if name in self.extra_filler_items:
self.extra_filler_items.remove(name)
classification = ItemClassification.filler
if not self.options.shopsanity and name in ITEM_GROUPS["Uncommon Plus Treasure"]:
# print(f"Changing classification for item {name}")
classification = DEPRIORITIZED_SKIP_BALANCING_FALLBACK
ap_code = self.item_name_to_id[name]
return SpiritTracksItem(name, classification, ap_code, self.player)
def build_item_pool_dict(self):
removed_item_quantities = self.options.remove_items_from_pool.value.copy()
item_pool_dict = {}
filler_item_count = 0
def pop_item_from_dict(item_dict, item):
item_dict[item] -= 1
if item_dict[item] <= 0:
item_dict.pop(item)
def pop_random_item_from_dict(item_dict):
i_name = self.random.choice([i for i in item_dict])
pop_item_from_dict(item_dict, i_name)
return i_name
for loc_name, loc_data in LOCATIONS_DATA.items():
# print(f"New Location: {loc_name} {filler_item_count}")
if not self.location_is_active(loc_name, loc_data):
# print(f"{loc_name} is not active")
continue
# If no defined vanilla item, fill with filler
if "vanilla_item" not in loc_data:
# print(f"\t{loc_name} has no defined vanilla item")
filler_item_count += 1
continue
item_name = loc_data.get("item_override", loc_data["vanilla_item"])
if isinstance(item_name, list | set):
item_name = self.random.choice(list(item_name))
item_data = ITEMS[item_name]
if item_name in removed_item_quantities and removed_item_quantities[item_name] > 0:
# If item was put in the "remove_items_from_pool" option, replace it with a random filler item
removed_item_quantities[item_name] -= 1
# print(f"\triq")
filler_item_count += 1
continue
if "rabbit" in item_data.tags:
if self.options.rabbitsanity == "vanilla" and not hasattr(self.multiworld, "generation_is_fake"): # Force vanilla rabbits randomly
realm = item_name.split()[0]
realm_pool = self.rabbit_realm_items[realm]
popped_item = pop_random_item_from_dict(realm_pool)
pop_item_from_dict(self.rabbit_item_dict, popped_item)
forced_item = self.create_item(popped_item)
self.multiworld.get_location(loc_name, self.player).place_locked_item(forced_item)
continue
# print(f"\trabbit")
filler_item_count += 1
continue
if "Tear of Light" in item_name:
if self.options.randomize_tears.value < 0:
forced_item = self.create_item(item_name)
self.multiworld.get_location(loc_name, self.player).place_locked_item(forced_item)
continue
# print(f"\ttear")
filler_item_count += 1
continue
if "stamp" in loc_data and self.options.randomize_stamps.value == 2:
forced_item = self.create_item(item_name)
self.multiworld.get_location(loc_name, self.player).place_locked_item(forced_item)
# print(f"Locking stamp item {item_name} to {loc_name}")
continue
if any([
item_name in ["Filler Item", "Treasure", "Nothing!",
"Heart Container", "Tear of Light",
"Shield", "Prize Postcards (10)", "Sand Source"],
item_name.startswith("Stamp"),
item_name in ITEM_GROUPS["All Rails"],
item_name in ITEM_GROUPS["Main Items"],
item_name in ITEM_GROUPS["All Treasures"],
item_name in ITEM_GROUPS["Rupee Items"],
self.options.randomize_cargo.value == 3 and item_name in ["Cargo: Cuccos", "Cargo: Mega Ice"],
self.options.keyrings.value >= 1 and item_name == "Mountain Temple Snurglar Key",
self.options.keyrings.value > 1 and item_name.startswith("Small Key ("),
self.options.keyrings.value > 1 and self.options.big_keyrings and item_name.startswith("Boss Key (") and item_name != "Boss Key (ToS 3)"
]):
# print(f"\tBig listicle {item_name}")
filler_item_count += 1
continue
if any([
"Small Key" in item_name and self.options.keysanity == "vanilla",
loc_name.endswith("Boss Key") and self.options.randomize_boss_keys == "vanilla_abstract",
"force_vanilla" in loc_data and loc_data["force_vanilla"],
self.options.randomize_passengers == "vanilla_abstract" and item_name.startswith("Passenger:"),
self.options.randomize_cargo == "vanilla_abstract" and item_name.startswith("Cargo:"),
item_name == "Mountain Temple Snurglar Key" and self.options.keysanity == "vanilla"
]):
# print(f"Forcing item {item_name} to location {loc_name}")
forced_item = self.create_item(item_name)
self.multiworld.get_location(loc_name, self.player).place_locked_item(forced_item)
continue
# if item_data.classification == ItemClassification.filler: # Regen all filler items for now
# filler_item_count += 1
# continue
item_pool_dict[item_name] = item_pool_dict.get(item_name, 0) + 1
# add progression items first
add_items = [("Bombs (Progressive)", 3), ("Bow (Progressive)", 3),
("Repair Trading Post Bridge", 1), ("Shield", 2), ("Treasure: Regal Ring", 1)]
add_items += [(i, 1) for i in ITEM_GROUPS["Non-Progressive Main Items"]]
if self.options.dark_realm_access in ["shattered_compass" or "both"] and self.options.compass_shard_total.value > 1:
add_items += [("Compass of Light Shard", self.options.compass_shard_total.value)]
else:
add_items += [("Compass of Light", 1)]
if self.options.rabbitsanity: add_items += [("Rabbit Net", 1)]
if self.options.randomize_cargo: add_items += [("Wagon", 1)]
if self.options.randomize_cargo.value == 3: add_items += [("Cargo: Mega Ice", 3), ("Cargo: Cuccos (5)", 3)]
# if self.options.shopsanity: add_items += [("Treasure: Regal Ring", 1), ("Treasure: Priceless Stone", 2)]
if self.options.randomize_stamps: add_items += self.stamp_items
add_items += self.choose_tos_items()
add_items += self.choose_key_items()
add_items += self.track_items
if self.options.portal_behavior.value == 2:
add_items += [(i, 1) for i in ITEM_GROUPS["Portal Unlocks"]]
add_items += self.choose_tear_items()
add_items += [i for i in self.rabbit_item_dict.items()]
add_items += [("Sword Beam Scroll", 1), ("Great Spin Scroll", 1), ("Heart Container", 13)]
# Add items
for i, count in add_items:
# print(f"\t{i}: {count}")
item_pool_dict, filler_item_count = add_items_from_filler(item_pool_dict, filler_item_count, i, count)
# Calculate rupee items for logic, and make sure there are enough filler items for excluded locations
item_pool_dict = self.choose_filler_items(filler_item_count, item_pool_dict)
return item_pool_dict
def choose_key_items(self):
res: list[tuple[str, int]] = []
if self.options.keyrings == 0:
return res
if self.options.keyrings == 1:
res += [("Snurglar Keyring", 1)]
elif self.options.keyrings >= 2:
keyrings = ITEM_GROUPS["Keyrings"].copy()
res += [("Small Key (Tunnel to ToS)", 1)]
if self.options.exclude_dungeons == "remove":
keyrings -= {f"Keyring ({i})" for i in self.non_required_dungeons}
if self.options.exclude_sections == "remove":
keyrings -= {f"Keyring (ToS {i})" for i in self.non_required_sections}
if not self.options.big_keyrings and not self.options.exclude_sections == "remove" and "Blizzard Temple" not in self.non_required_dungeons:
keyrings -= {"Keyring (Blizzard Temple)"}
res += [("Small Key (Blizzard Temple)", 1)]
if self.options.keyrings == 3:
keyrings = list(keyrings)
keyrings.sort()
self.random.shuffle(keyrings)
keyring_count = self.random.randint(0, len(keyrings))
chosen_keyrings = keyrings[:keyring_count]
# print(f"Chosen keys: {chosen_keyrings}")
vanilla_keys = ["(" + i.split("(")[1] for i in keyrings[keyring_count:] if i != "Snurglar Keyring"]
if "Snurglar Keyring" in keyrings[keyring_count:]:
res += [("Mountain Temple Snurglar Key", 3)]
for dungeon in vanilla_keys:
key = f"Small Key {dungeon}"
res += [(key, KEY_COUNTS[key])]
if self.options.big_keyrings:
big_key = f"Boss Key {dungeon}"
if big_key not in ITEMS:
continue
res += [(big_key, 1)]
keyrings = chosen_keyrings
res += [(i, 1) for i in keyrings]
# print(f"Key Items: {res}")
return res
def choose_track_items(self):
option = self.options.track_pool
track_items = set()
if option == "vanilla":
track_items = ITEM_GROUPS["Basic Tracks"]
elif option == "major_minor":
track_items = ITEM_GROUPS["Major Track Groupings"] | ITEM_GROUPS["Minor Track Groupings"]
elif option == "completed_glyphs":
track_items = ITEM_GROUPS["Completed Track Groupings"]
elif option == "thematic":
track_items = ITEM_GROUPS["Thematic Track Groupings"]
elif option.value < 0: # Random mixed
skip_pools = set()
for pool_name, pool in ITEM_GROUPS.items():
if not pool_name.startswith("Tracks:") or pool_name in skip_pools:
continue
valid_choices = pool.copy()
if option == "mixed_large":
valid_choices -= ITEM_GROUPS["Basic Tracks"]
elif option == "mixed_small":
valid_choices -= ITEM_GROUPS["Completed Track Groupings"]
new_track = self.random.choice(list(valid_choices))
track_items.add(new_track)
skip_pools.update([i for i in ITEMS[new_track].item_groups if i.startswith("Tracks:")])
add_items = [(i, 1) for i in track_items]
# print(len(add_items), add_items)
if self.options.start_with_train:
valid_starting_tracks = [track for track in track_items if track in ITEM_GROUPS["Tracks: Forest Glyph"]]
self.options.start_inventory_from_pool.value.update({self.random.choice(valid_starting_tracks): 1})
if self.options.cannon_logic.value in [0, 1]:
self.options.start_inventory_from_pool.value.update({"Cannon": 1})
return add_items
def choose_filler_items(self, filler_count, item_pool_dict):
rupees_required = self.get_required_rupees()
required_filler = len(self.locations_to_exclude)
max_non_filler = filler_count - required_filler
# print(f"Filler Count: {filler_count} | Excluded {required_filler} remaining {max_non_filler}")
if max_non_filler <= 0:
raise FillError(f"Not enough room in item pool for filler items, please adjust your settings.")
# Start with 60% of the remaining filler pool as rupee items, and cascade down until you've got 3 times the required rupees.
cascade = [99, 100, 150, 200, 300, 500, 2500]
filler_values = [2500]*((max_non_filler*6)//10)
total_rupees = rupees_required*2+2500
# print(f"Need {rupees_required} rupees, starting with pool of {len(filler_values)} value {sum(filler_values)} for target {total_rupees}")
if sum(filler_values) < total_rupees:
filler_values += [2500]*math.ceil((total_rupees-sum(filler_values))/2400)
print(f"Not enough room in filler pool for rupees, adding more regal rings")
while sum(filler_values) > total_rupees:
i = self.random.choice(filler_values)
filler_values.remove(i)
if i != 100:
filler_values.append(cascade[cascade.index(i) - 1])
# Create items for the corresponding values
rupee_choices = {99: "Big Green Rupee (100)",
100: "Big Green Rupee (100)",
150: list(ITEM_GROUPS["Uncommon Treasures"]),
200: "Big Red Rupee (200)",
300: "Gold Rupee (300)",
500: list(ITEM_GROUPS["Rare Treasures"]),
2500: list(ITEM_GROUPS["Super Rare Treasures"])}
# print(f"Rupee pool: {filler_values}")
for i in filler_values:
if isinstance(rupee_choices[i], str):
item_pool_dict[rupee_choices[i]] = item_pool_dict.get(rupee_choices[i], 0) + 1
else:
choice = self.random.choice(rupee_choices[i])
item_pool_dict[choice] = item_pool_dict.get(choice, 0) + 1
filler_count -= len(filler_values)
# print(f"Rupee item dict: {[(i, v) for i, v in item_pool_dict.items() if i in ITEM_GROUPS['Rupee Pool Items']]}")
# Get filler items for the remaining items
for _ in range(filler_count):
random_filler_item = self.get_filler_item_name()
item_pool_dict[random_filler_item] = item_pool_dict.get(random_filler_item, 0) + 1
# print(f"Filler item dict: {[(i, v) for i, v in item_pool_dict.items() if i in ITEM_GROUPS['Filler Item Pool']]}")
return item_pool_dict
def choose_tos_items(self):
res = []
if self.options.tos_section_unlocks in ["progressive"]:
prog_count = 4
if self.options.tos_unlock_base_item:
prog_count = 5
res += [("Progressive ToS Section", prog_count)]
elif self.options.tos_unlock_base_item:
res += [("Tower of Spirits Base", 1)]
return res
def choose_rabbit_locations(self):
if not self.options.rabbitsanity:
return []
rabbit_locations = []
# Figure out rabbit counts for different pools
max_count = self.options.rabbit_max_location_count.value
rabbit_counts = [max_count]*5
if self.options.rabbit_location_count_distribution.value == -1:
rabbit_counts = [self.random.randint(1, max_count) for _ in range(5)]
self.rabbit_counts = rabbit_counts
def pick_random_locs(loc_lists):
[self.random.shuffle(i) for i in loc_lists]
return [loc for rl, c in zip(loc_lists, rabbit_counts) for loc in rl[:c]]
# Figure out pools
if self.options.rabbitsanity.value in [1, 2, 4]: # Vanilla or unique
forest_rabbits = list(LOCATION_GROUPS["Unique Grass Rabbits"])
snow_rabbits = list(LOCATION_GROUPS["Unique Snow Rabbits"])
ocean_rabbits = list(LOCATION_GROUPS["Unique Ocean Rabbits"])
mountain_rabbits = list(LOCATION_GROUPS["Unique Mountain Rabbits"])
sand_rabbits = list(LOCATION_GROUPS["Unique Sand Rabbits"])
rabbit_locations += pick_random_locs([forest_rabbits, snow_rabbits, ocean_rabbits, mountain_rabbits, sand_rabbits])
if self.options.rabbitsanity.value in [3, 4]: # total count
forest_rabbits = list(LOCATION_GROUPS["Total Grass Rabbits"])
snow_rabbits = list(LOCATION_GROUPS["Total Snow Rabbits"])
ocean_rabbits = list(LOCATION_GROUPS["Total Ocean Rabbits"])
mountain_rabbits = list(LOCATION_GROUPS["Total Mountain Rabbits"])
sand_rabbits = list(LOCATION_GROUPS["Total Sand Rabbits"])
sort_func = lambda loc: f"0{loc.split()[1]}"[-2:] # wth python
forest_rabbits.sort(key=sort_func)
snow_rabbits.sort(key=sort_func)
ocean_rabbits.sort(key=sort_func)
mountain_rabbits.sort(key=sort_func)
sand_rabbits.sort(key=sort_func)
interval = self.options.rabbit_location_count_distribution.value
rabbit_lists = [forest_rabbits, snow_rabbits, ocean_rabbits, mountain_rabbits, sand_rabbits]
if interval >= 0:
intervals = [interval]*5 if interval else [self.random.randint(1, 3) for _ in range(3)]
for i, realm_locs in zip(intervals, rabbit_lists):
if i > max_count:
rabbit_locations.append(realm_locs[max_count-1])
else:
rabbit_locations += realm_locs[i-1:max_count:i]
# print(f"Rabbit Locations: {rabbit_counts} {intervals} {rabbit_locations}")
return rabbit_locations
if self.options.rabbitsanity == "both": # Randomize each pool count separately
self.rabbit_counts = [self.random.randint(1, max_count) for _ in range(5)]
rabbit_lists = [r[:max_count] for r in rabbit_lists]
rabbit_locations += pick_random_locs(rabbit_lists)
# print(f"Rabbit Locations: {rabbit_counts} {rabbit_locations}")
return rabbit_locations
def choose_rabbit_items(self):
if not self.options.rabbitsanity:
return {}
def get_rabbit_pack_name(realm, count):
if count == 1:
return f"{realm} Rabbit"
return f"{realm} Rabbits ({count})"
def create_items_from_count_list(realm, clist):
res = {}
for count in clist:
item_name = get_rabbit_pack_name(realm, count)
res.setdefault(item_name, 0)
res[item_name] += 1
# print(f"Creating rabbit items: {res}")
return res
def fill_vanilla(realm, max_count):
count_distr = [1]*max_count
if max_count == 1:
return {get_rabbit_pack_name(realm, 10): 1}
res_counts = []
# print(f"Filling vanilla rabbits {realm} {max_count}")
while sum(count_distr) + sum(res_counts) < 10:
randindex = self.random.randint(0, len(count_distr)-1)
count_distr[randindex] += 1
if count_distr[randindex] == 5:
res_counts.append(count_distr.pop(randindex))
res_counts += count_distr
res_counts += [1]*self.options.rabbit_extra_items.value # Add bonus items
return create_items_from_count_list(realm, res_counts)
def fill_mixed(realm):
res_counts = []
while sum(res_counts) < 10:
res_counts.append(round(self.random.triangular(0.5, 5.5, 2)))
for i in range(self.options.rabbit_extra_items.value):
res_counts.append(round(self.random.triangular(0.5, 5.5, 2)))
return create_items_from_count_list(realm, res_counts)
realms = rabbit_realms
rabbit_items = {}
if self.options.rabbitsanity.value == 1: # Vanilla
# print(f"Vanilla rabbits {self.rabbit_counts}")
self.options.rabbit_pack_size.value = 1
for r, c in zip(realms, self.rabbit_counts):
vanilla_pool = fill_vanilla(r, c)
rabbit_items |= vanilla_pool
self.rabbit_realm_items[r] = vanilla_pool
return rabbit_items
if self.options.rabbit_pack_size == -1: # random_mixed
for r in realms:
rabbit_items |= fill_mixed(r)
return rabbit_items
# Uniform packs
if self.options.rabbit_pack_size == 0: # Random uniform
pack_sizes = [self.random.randint(1, 5), self.random.randint(1, 5)]
else:
pack_sizes = [self.options.rabbit_pack_size.value]*5
# print(f"Uniform Packs {pack_sizes}")
for r, s in zip(realms, pack_sizes):
item_count = math.ceil(10 / s) + self.options.rabbit_extra_items.value
rabbit_items |= create_items_from_count_list(r, [s]*item_count)
return rabbit_items
def choose_tear_items(self):
size_index = self.options.tear_size.value
spirit_weapon = self.options.spirit_weapons.value
add_items = []
if not spirit_weapon:
add_items += [("Sword (Progressive)", 2), ("Bow of Light", 1)]
else:
add_items += [("Sword", 1)]
if self.options.exclude_sections == "remove" and len(self.non_required_sections) == 6 and self.options.randomize_tears.value not in [3, 0]:
return add_items
if self.options.randomize_tears.value <= 0:
return add_items
size_str = ["", "Big "][size_index]
sections = range(1, 6)
if self.options.exclude_sections == "remove":
sections = [s for s in sections if s not in self.non_required_sections]
tear_sections = self.options.tear_sections.value
count_normal = [3, 1][size_index]
if tear_sections == 0 and self.options.randomize_tears not in ["no_tears", "vanilla"]: # unique section
add_items += [(f"{size_str}Tear of Light (ToS {section})", count_normal) for section in sections]
if (self.options.randomize_tears == "in_own_section"
and self.options.keysanity == "in_own_section"
and self.options.tear_size == "small"
and self.tower_section_lookup[6] < 6):
add_items.pop(-1)
add_items.append(("Big Tear of Light (ToS 6)", 1))
elif tear_sections == 1: # All Sections
add_items += [(f"{size_str}Tear of Light (All Sections)", count_normal + spirit_weapon)]
elif tear_sections == 2: # progressive
section_count = min(self.sections_included, 5)
count_prog = [section_count*3, section_count][size_index]
add_items += [(f"{size_str}Tear of Light (Progressive)", count_prog + spirit_weapon)]
# print(f"New Tear Items: {add_items}")
return add_items
def choose_stamp_items(self):
if self.options.randomize_stamps.value == 0:
return
self.stamp_items = [("Stamp Book", 1)]
if self.options.randomize_stamps.value in [1, 2, 4]:
return
if self.options.stamp_pack_sizes.value == 1:
self.stamp_items += [(s, 1) for s in ITEM_GROUPS["Stamps"]]
return
self.stamp_pack_order = list(range(20))
self.random.shuffle(self.stamp_pack_order)
if self.options.stamp_pack_sizes.value > 1:
item_count = math.ceil(20/self.options.stamp_pack_sizes.value)
self.stamp_items += [(f"Stamp Pack ({self.options.stamp_pack_sizes.value})", item_count)]
elif self.options.stamp_pack_sizes.value == -1:
sizes = []
while sum(sizes) < 20:
sizes.append(math.floor(self.random.triangular(1, 6, 1)))
stamp_value_lookup = {ITEMS[name].value: name for name in ITEM_GROUPS["Stamps"]}
stamp_dict = {i: 0 for i in range(2, 6)}
for s in sizes:
if s == 1:
self.stamp_items.append((stamp_value_lookup[self.stamp_pack_order.pop()], 1))
else:
stamp_dict[s] += 1
self.stamp_items += [(f"Stamp Pack ({size})", count) for size, count in stamp_dict.items() if count > 0]
def create_items(self):
item_pool_dict = self.build_item_pool_dict()
self.get_extra_filler_items(item_pool_dict)
items = []
for item_name, quantity in item_pool_dict.items():
for _ in range(quantity):
items.append(self.create_item(item_name))
self.filter_confined_dungeon_items_from_pool(items)
self.multiworld.itempool.extend(items)
def get_extra_filler_items(self, item_pool_dict):
# Create a random list of useful or currency items to turn into filler to satisfy all removed locations
filler_count = 0
extra_items_list = []
for item, count in item_pool_dict.items():
if 'backup_filler' in ITEMS[item].tags:
extra_items_list.extend([item] * count)
if ITEMS[item].classification in [ItemClassification.filler, ItemClassification.trap]:
filler_count += count
extra_item_count = len(self.locations_to_exclude) - filler_count + 20
if extra_item_count > 0:
self.random.shuffle(extra_items_list)
self.extra_filler_items = extra_items_list[:extra_item_count]
# Based on the messenger's plando connection by Aaron Wagner
def connect_plando(self, plando_pairings) -> None:
def remove_dangling_exit(region: Region) -> None:
# find the disconnected exit and remove references to it
for _exit in region.exits:
if not _exit.connected_region:
break
else:
raise ValueError(f"Unable to find randomized transition for {region}")
region.exits.remove(_exit)
def remove_dangling_entrance(region: Region) -> None:
# find the disconnected entrance and remove references to it
for _entrance in region.entrances:
if not _entrance.parent_region:
break
else:
raise ValueError(f"Invalid target region for {region}")
region.entrances.remove(_entrance)
for entr_name, exit_name in plando_pairings:
# get the connecting regions
r1 = ENTRANCES[entr_name]
reg1 = self.get_region(r1.entrance_region)
remove_dangling_exit(reg1)
r2 = ENTRANCES[exit_name]
reg2 = self.get_region(r2.entrance_region)
remove_dangling_entrance(reg2)
# connect the regions
reg1.connect(reg2)
if dev_prints:
print(f"Plando Connecting {r1} => {r2} with regions {reg1} => {reg2}")
# pretend the user set the plando direction as "both" regardless of what they actually put on coupled
if True:
remove_dangling_exit(reg2)
remove_dangling_entrance(reg1)
reg2.connect(reg1)
if dev_prints:
print(f"Connecting backwards {r2} => {r1}")
@staticmethod
def create_er_target_groups(type_option_lookup):
directions = [5, 6]
entr_types = [11 << 3]
return {5 + (11 << 3): [6 + (11 << 3)],
6 + (11 << 3): [5 + (11 << 3)]}
def connect_entrances(self) -> None:
if self.is_ut:
disconnect_ids = {int(i) for i in self.ut_pairings.keys()}
for e in self.valid_entrances:
if ENTRANCES[e.name].id in disconnect_ids:
# print(f"Disconnecting {e.name}")
target_name = ENTRANCES[e.name].vanilla_reciprocal.name
disconnect_entrance_for_randomization(e, one_way_target_name=target_name)
if getattr(self.multiworld, "enforce_deferred_connections", "default") == "off":
print(f"Reconnecting entrances {self.ut_pairings}")
for i, pairing in self.ut_pairings.items():
_exit: "Entrance" = self.get_entrance(entrance_id_to_entrance[int(i)].name)
entrance_region: "Region" = self.get_region(entrance_id_to_region[pairing])
_exit.connect(entrance_region)
return
# Choose entrances to shuffle based on settings
type_option_lookup = {
11: self.options.shuffle_tos_sections
}
entrances_to_shuffle: list["Entrance"] = []
for e in self.valid_entrances:
# print(f"ER: {e.name} {bin(e.randomization_group)} {bin(EntranceGroups.AREA_MASK)} {(e.randomization_group & EntranceGroups.AREA_MASK) >> 3}")
if type_option_lookup.get((e.randomization_group & EntranceGroups.AREA_MASK) >> 3, False):
entrances_to_shuffle.append(e)
# Disconnect entrances to shuffle
for entrance in entrances_to_shuffle:
if dev_prints:
print(f"Disconnecting {entrance.name}")
target_name = ENTRANCES[entrance.name].vanilla_reciprocal.name
disconnect_entrance_for_randomization(entrance, one_way_target_name=target_name)
# Get target groups
groups = self.create_er_target_groups(type_option_lookup)
# print(f"Shuffling Entrances {entrances_to_shuffle} with groups {groups}")
# Entrance Rando
if self.tower_pairings:
self.connect_plando(self.tower_pairings)
self.er_placement_state = randomize_entrances(self, True, groups)
# print(f"ER Placements: {self.er_placement_state.pairings}")
def get_pre_fill_items(self):
return self.pre_fill_items
def pre_fill(self) -> None:
self.pre_fill_tos_sections()
self.pre_fill_dungeon_items()
def filter_confined_dungeon_items_from_pool(self, items: List[SpiritTracksItem]):
confined_dungeon_items = []
# Confine small keys and boss key to own dungeon if option is enabled
if self.options.keysanity in ["in_own_dungeon", "in_own_section"]:
confined_dungeon_items.extend([item for item in items if item.name.startswith("Small Key") or item.name.startswith("Keyring (")])
if self.options.randomize_boss_keys in ["in_own_dungeon", "in_own_section"]:
confined_dungeon_items.extend([item for item in items if item.name in ITEM_GROUPS["Boss Keys"]])
if self.options.randomize_tears in ["in_own_section", "in_tos"]:
confined_dungeon_items.extend([item for item in items if item.name in ITEM_GROUPS["Tears of Light"]])
for item in confined_dungeon_items:
items.remove(item)
self.pre_fill_items.extend(confined_dungeon_items)
# print(f"Pre fill items {self.pre_fill_items}")
def pre_fill_tos_sections(self):
for section in range(1, 7):
section_names = [name for name, loc in LOCATIONS_DATA.items()
if loc.get("tos_section", 0) == section]
section_locations = [loc for loc in self.multiworld.get_locations(self.player)
if loc.name in section_names and not loc.locked]
section_items = []
if self.options.keysanity == "in_own_section":
section_items += [item for item in self.pre_fill_items if item.name == f"Small Key (ToS {section})" or item.name == f"Keyring (ToS {section})"]
if self.options.randomize_boss_keys == "in_own_section":
section_items += [item for item in self.pre_fill_items if item.name == f"Boss Key (ToS {section})"]
if self.options.randomize_tears == "in_own_section":
section_items += [item for item in self.pre_fill_items if item.name.endswith(f"Tear of Light (ToS {section})")]
if len(section_locations) == 0:
continue
# print(f"Pre filling section {section}: {section_items}")
# print(f"\tlocations {section_locations}")
# Remove from the all_state the items we're about to place
for item in section_items:
self.pre_fill_items.remove(item)
collection_state = self.multiworld.get_all_state(False)
# Perform a prefill to place confined items inside locations of this dungeon
self.random.shuffle(section_locations)
# print(f"Pre filling section {section}: {section_items} to {section_locations}")
fill_restrictive(self.multiworld, collection_state, section_locations, section_items,
single_player_placement=True, lock=True, allow_excluded=True)
def pre_fill_dungeon_items(self):
# If keysanity is off, dungeon items can only be put inside local dungeon locations, and there are not so many
# of those which makes them pretty crowded.
# This usually ends up with generator not having anywhere to place a few small keys, making the seed unbeatable.
# To circumvent this, we perform a restricted pre-fill here, placing only those dungeon items
# before anything else.
for dung_name in DUNGEON_NAMES:
# Build a list of locations in this dungeon
# print(f"Pre-filling {dung_name}")
dungeon_location_names = [name for name, loc in LOCATIONS_DATA.items()
if "dungeon" in loc and loc["dungeon"] == dung_name]
dungeon_locations = [loc for loc in self.multiworld.get_locations(self.player)
if loc.name in dungeon_location_names and not loc.locked]
# From the list of all dungeon items that needs to be placed restrictively, only filter the ones for the
# dungeon we are currently processing.
confined_dungeon_items = [item for item in self.pre_fill_items
if item.name.endswith(f"({dung_name})")]
if dung_name == "ToS":
if self.options.keysanity == "in_own_dungeon":
confined_dungeon_items += [item for item in self.pre_fill_items
if item.name.startswith("Small Key (ToS") or item.name.startswith("Keyring (ToS")]
if self.options.randomize_boss_keys == "in_own_dungeon":
confined_dungeon_items += [item for item in self.pre_fill_items
if item.name.startswith("Boss Key (ToS")]
if self.options.randomize_tears == "in_tos":
confined_dungeon_items += [item for item in self.pre_fill_items
if "Tear of Light" in item.name]
# print(f"pre filling {dung_name}: {confined_dungeon_items}")
# print(f"\tlocations {dungeon_location_names}")
if len(confined_dungeon_items) == 0:
continue # This list might be empty with some keysanity options
# Remove from the all_state the items we're about to place
for item in confined_dungeon_items:
self.pre_fill_items.remove(item)
collection_state = self.multiworld.get_all_state(False)
# Perform a prefill to place confined items inside locations of this dungeon
self.random.shuffle(dungeon_locations)
#print(f"{dungeon_locations}, {confined_dungeon_items}")
fill_restrictive(self.multiworld, collection_state, dungeon_locations, confined_dungeon_items,
single_player_placement=True, lock=True, allow_excluded=True)
def get_filler_item_name(self) -> str:
filler_item_names = list(ITEM_GROUPS["Common Treasures"] |
# ITEM_GROUPS["Uncommon Treasures"] |
ITEM_GROUPS["Refill Items"] |
ITEM_GROUPS["Small Rupees"]
) + ["Big Green Rupee (100)"]
rare_filler_items = list(ITEM_GROUPS["Potions"])
# 1/5 chance to roll a rare filler item
if self.random.randint(1, 15) == 1:
return self.random.choice(rare_filler_items)
return self.random.choice(filler_item_names)
def collect(self, state: CollectionState, item: SpiritTracksItem) -> bool:
# Code borrowed from Ishigh's early Rule Builder implementation
change = super().collect(state, item)
if not change:
return False
mapping = self.item_mapping_collect.get(item.name, None)
if mapping is not None:
# print(f"Mapping {mapping} {state.prog_items[self.player][mapping[0]]} for item {item.name}")
state.prog_items[self.player][mapping[0]] += mapping[1]
return True
def remove(self, state: CollectionState, item: SpiritTracksItem) -> bool:
change = super().remove(state, item)
if not change:
return False
mapping = self.item_mapping_collect.get(item.name, None)
if mapping is not None:
state.prog_items[self.player][mapping[0]] -= mapping[1]
return True
# def post_fill(self) -> None:
# self.get_location_models()
def get_location_models(self):
# get item placement models to send to client
location_models = {}
for loc in self.get_locations():
item = loc.item
if item is None: continue
loc_data = LOCATIONS_DATA.get(loc.name, {})
if not loc_data or 'stamp' in loc_data or 'no_model' in loc_data:
continue
if item.game in ["Spirit Tracks"]:
if ITEMS[item.name].model is not None:
location_models[loc_data['id']] = ITEM_MODEL_LOOKUP[ITEMS[item.name].model].offset
continue
elif item.game in all_lookups:
model = all_lookups[item.game].get(item.name, None)
if model is not None:
location_models[loc_data['id']] = model
print(f"Setting item model for {item}")
continue
if item.classification & ItemClassification.progression:
pass # progression fallback is the default
elif item.classification & ItemClassification.trap:
location_models[loc_data['id']] = ITEM_MODEL_LOOKUP["Stalfos Skull"].offset
elif item.classification & ItemClassification.useful:
location_models[loc_data['id']] = ITEM_MODEL_LOOKUP["Blue Rupee"].offset
else:
location_models[loc_data['id']] = ITEM_MODEL_LOOKUP["Green Rupee"].offset
return location_models
# print(f"Location Models: {location_models}")
def fill_slot_data(self) -> dict:
options = ["goal", "compass_shard_count",
"logic", "cannon_logic",
"exclude_dungeons", "exclude_sections",
"keysanity", "randomize_boss_keys",
"big_keyrings",
"randomize_minigames", "minigame_hints",
"rabbitsanity", # "rabbit_hints",
"randomize_passengers", "randomize_cargo",
"exclude_locations",
"portal_behavior", "portal_checks",
"randomize_tears", "spirit_weapons", "tear_sections",
"dark_realm_access", "endgame_scope", "dungeons_required",
"starting_train",
"randomize_stamps",
"tos_section_unlocks", "tos_unlock_base_item", "shuffle_tos_sections",
"shopsanity", "shop_hints", "rupee_farming_logic", "excess_random_treasure",
"death_link"]
slot_data = self.options.as_dict(*options)
slot_data["active_rabbit_locs"] = [LOCATIONS_DATA[loc]["id"] for loc in self.active_rabbit_locations]
# print(f"Required Dungeons: {self.required_dungeons}")
slot_data["required_dungeons"] = [self.location_name_to_id[i] for i in self.required_dungeons]
slot_data["stamp_pack_order"] = self.stamp_pack_order
slot_data["model_lookup"] = self.get_location_models()
slot_data["exclude_tos_5"] = self.exclude_tos_5
pairings = {}
if self.er_placement_state:
for e1, e2 in self.er_placement_state.pairings:
pairings[ENTRANCES[e1].id] = ENTRANCES[e2].id
pairings |= self.plando_pairings
slot_data["er_pairings"] = pairings
slot_data["tower_section_lookup"] = self.tower_section_lookup
slot_data["section_count"] = self.sections_included
# print(f"ER Pairings: {pairings}")
return slot_data
def write_spoiler(self, spoiler_handle):
if self.options.dark_realm_access in ["dungeons", "both"]:
title_str = "Required Dungeons" if self.options.require_specific_dungeons else "Dungeon Locations"
spoiler_handle.write(f"\n\n{title_str} ({self.multiworld.player_name[self.player]}):\n")
for dung in self.required_dungeons:
spoiler_handle.write(f"\t- {dung}\n")
# UT stuff
@staticmethod
def interpret_slot_data(slot_data: dict[str, Any]):
return slot_data
def reconnect_found_entrances(self, key, stored_data):
print(f"UT Tried to defer entrances! key {key}"
f" {stored_data}"
)
if getattr(self.multiworld, "enforce_deferred_connections", "default") == "off":
print(f"Don't defer entrances when off")
if "st_checked_entrances" in key and stored_data:
new_connections = set(stored_data) - self.ut_checked_entrances
self.ut_checked_entrances |= new_connections
for i in new_connections:
pairing = self.ut_pairings.get(str(i), None)
# print(f"Pairing {pairing} {entrance_id_to_entrance[i].name}")
if pairing is not None:
_exit: "Entrance" = self.get_entrance(entrance_id_to_entrance[i].name)
entrance_region: "Region" = self.get_region(entrance_id_to_region[pairing])
print(f"Connecting: {_exit} => {entrance_region} | {i}: {pairing}")
_exit.connect(entrance_region)