Files
dockipelago/worlds/tloz_ooa/__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

542 lines
24 KiB
Python

import logging
import os
import yaml
import settings
from BaseClasses import Tutorial, Region, Location, LocationProgressType
from Fill import fill_restrictive, FillError
from Options import Accessibility, OptionError
from worlds.AutoWorld import WebWorld, World
from typing import Any, Set, List, Dict, Optional, Tuple, ClassVar, TextIO, Union
from .Data import *
from .data.Items import *
from .Logic import create_connections, apply_self_locking_rules
from .Options import *
from .PatchWriter import ooa_create_appp_patch
from .data import LOCATIONS_DATA
from .data.Constants import *
from .data.Regions import REGIONS
from .Client import OracleOfAgesClient # Unused, but required to register with BizHawkClient
from .patching.ProcedurePatch import ROM_HASH
class OOASettings(settings.Group):
class OOARomFile(settings.UserFilePath):
"""File path of the OOA US rom"""
description = "Oracle of Ages (USA) ROM File"
copy_to = "Legend of Zelda, The - Oracle of Ages (USA).gbc"
md5s = [ROM_HASH]
class OoACharacterSprite(str):
"""
The name of the sprite file to use (from "data/sprites/oos_ooa/").
Putting "link" as a value uses the default game sprite.
Putting "random" as a value randomly picks a sprite from your sprites directory for each generated ROM.
"""
class OoACharacterPalette(str):
"""
The color palette used for character sprite throughout the game.
Valid values are: "green", "red", "blue", "orange", and "random"
"""
class OoAHeartBeepInterval(str):
"""
A factor applied to the infamous heart beep sound interval.
Valid values are: "vanilla", "half", "quarter", "disabled"
"""
class OoAQolMermaidSuit(str):
"""
Defines if you don't want to spam the buttons to swim with the mermaid suit.
"""
class OoAQuickFlute(str):
"""
When enabled, playing the flute and the harp will immobilize you during a very small amount of time compared to vanilla game.
"""
class OoASkipTokkeyDance(str):
"""
Defines if you want to skip the small dance that tokkay does
"""
class OoASkipSadBoiJoke(str):
"""
Defines if you want to skip the joke you tell to the sad boi
"""
rom_file: OOARomFile = OOARomFile(OOARomFile.copy_to)
heart_beep_interval: Union[OoAHeartBeepInterval, str] = "vanilla"
character_sprite: Union[OoACharacterSprite, str] = "link"
character_palette: Union[OoACharacterPalette, str] = "green"
qol_mermaid_suit: Union[OoAQolMermaidSuit, bool] = True
qol_quick_flute: Union[OoAQuickFlute, bool] = True
skip_tokkey_dance: Union[OoASkipTokkeyDance, bool] = False
skip_boi_joke: Union[OoASkipSadBoiJoke, bool] = False
class OracleOfAgesWeb(WebWorld):
theme = "grass"
tutorials = [Tutorial(
"Multiworld Setup Guide",
"A guide to setting up Oracle of Ages for Archipelago on your computer.",
"English",
"ooa_setup_en.md",
"ooa_setup/en",
["Dinopony"]
)]
class OracleOfAgesWorld(World):
"""
The Legend of Zelda: Oracles of Ages is one of the rare Capcom entries to the series.
Nayru, the oracle of ages, has been possessed by Veran, and she is now making a mess in Labrynna
Gather the Essences of Times, exorcice Nayru and defeat Veran to save the timeline of Labrynna
"""
game = "The Legend of Zelda - Oracle of Ages"
options_dataclass = OracleOfAgesOptions
options: OracleOfAgesOptions
required_client_version = (0, 5, 1)
web = OracleOfAgesWeb()
topology_present = True
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
pre_fill_items: List[Item]
dungeon_items: List[Item]
dungeon_entrances: Dict[str, str]
shop_prices: Dict[str, int]
settings: ClassVar[OOASettings]
settings_key = "tloz_ooa_options"
def __init__(self, multiworld, player):
super().__init__(multiworld, player)
self.pre_fill_items = []
self.dungeon_items = []
self.dungeon_entrances = DUNGEON_ENTRANCES.copy()
self.shop_prices = SHOP_PRICES_DIVIDERS.copy()
def fill_slot_data(self) -> dict:
# Put options that are useful to the tracker inside slot data
# TODO MOAR DATA ?
options = ["goal", "death_link",
# Logic-impacting options
"logic_difficulty",
"shuffle_dungeons",
"default_seed",
# Locations
"advance_shop",
# Requirements
"required_essences", "required_slates",
# keysanity
"keysanity_small_keys", "keysanity_boss_keys", "keysanity_slates"
]
slot_data = self.options.as_dict(*options)
slot_data["animal_companion"] = COMPANIONS[self.options.animal_companion.value]
slot_data["default_seed"] = SEED_ITEMS[self.options.default_seed.value]
slot_data["dungeon_entrances"] = self.dungeon_entrances
return slot_data
def generate_early(self):
conflicting_rings = self.options.required_rings.value & self.options.excluded_rings.value
if len(conflicting_rings) > 0:
raise OptionError("Required Rings and Excluded Rings contain the same element(s)", conflicting_rings)
self.restrict_non_local_items()
if self.options.shuffle_dungeons == "shuffle":
self.shuffle_dungeons()
self.randomize_shop_prices()
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_small_keys:
self.options.non_local_items.value -= self.item_name_groups["Small Keys"]
if not self.options.keysanity_boss_keys:
self.options.non_local_items.value -= self.item_name_groups["Boss Keys"]
if not self.options.keysanity_maps_compasses:
self.options.non_local_items.value -= self.item_name_groups["Dungeon Maps"]
self.options.non_local_items.value -= self.item_name_groups["Compasses"]
if not self.options.keysanity_slates:
self.options.non_local_items.value -= set(["Slate"])
def shuffle_dungeons(self):
shuffled_dungeons = list(self.dungeon_entrances.values())
while True:
self.random.shuffle(shuffled_dungeons)
if shuffled_dungeons[4] != "enter d0": # Ensure D4 entrance doesn't lead to d0
break
self.dungeon_entrances = dict(zip(self.dungeon_entrances, shuffled_dungeons))
def randomize_shop_prices(self):
prices_pool = get_prices_pool()
self.random.shuffle(prices_pool)
global_prices_factor = self.options.shop_prices_factor.value / 100.0
for key, divider in self.shop_prices.items():
floating_price = prices_pool.pop() * global_prices_factor / divider
for i, value in enumerate(VALID_RUPEE_VALUES):
if value > floating_price:
self.shop_prices[key] = VALID_RUPEE_VALUES[i-1]
break
def location_is_active(self, location_name, location_data):
if "conditional" not in location_data or location_data["conditional"] is False:
return True
region_id = location_data["region_id"]
if region_id == "advance shop":
return self.options.advance_shop.value
# TODO FUNNY LOCATION ?
return False
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_problematic_locations()
def create_event(self, region_name, event_item_name):
region = self.multiworld.get_region(region_name, self.player)
location = Location(self.player, region_name + ".event", None, region)
region.locations.append(location)
location.place_locked_item(Item(event_item_name, ItemClassification.progression, None, self.player))
def create_events(self):
self.create_event("maku seed", "Maku Seed")
if self.options.goal == OracleOfAgesGoal.option_beat_veran:
self.create_event("veran beaten", "_beaten_game")
elif self.options.goal == OracleOfAgesGoal.option_beat_ganon:
self.create_event("ganon beaten", "_beaten_game")
self.create_event("ridge move vine seed", "_access_cart")
self.create_event("d3 S crystal", "_d3_S_crystal")
self.create_event("d3 E crystal", "_d3_E_crystal")
self.create_event("d3 W crystal", "_d3_W_crystal")
self.create_event("d3 N crystal", "_d3_N_crystal")
self.create_event("d3 B1F spinner", "_d3_B1F_spinner")
self.create_event("d6 wall B bombed", "_d6_wall_B_bombed")
self.create_event("d6 canal expanded", "_d6_canal_expanded")
self.create_event("d7 boss", "_finished_d7")
def exclude_problematic_locations(self):
locations_to_exclude = []
# If goal essence requirement is set to a specific value, prevent essence-bound checks which require more
# essences than this goal to hold anything of value
#if self.options.required_essences < 7:
# locations_to_exclude.append("Horon Village: Item Inside Maku Tree (7+ Essences)")
# if self.options.required_essences < 5:
# locations_to_exclude.append("Horon Village: Item Inside Maku Tree (5+ Essences)")
# if self.options.required_essences < 3:
# locations_to_exclude.append("Horon Village: Item Inside Maku Tree (3+ Essences)")
# TODO PROBLEMATIC LOCATIONS
for name in locations_to_exclude:
self.multiworld.get_location(name, self.player).progress_type = LocationProgressType.EXCLUDED
def set_rules(self):
create_connections(self.multiworld, self.player)
apply_self_locking_rules(self.multiworld, self.player)
self.multiworld.completion_condition[self.player] = lambda state: state.has("_beaten_game", self.player)
def create_item(self, name: str) -> Item:
if name.endswith("!PROG"):
# If item name has a "!PROG" suffix, force it to be progression. This is typically used to create the right
# amount of progression rupees while keeping them a filler item as default
name = name.removesuffix("!PROG")
classification = ItemClassification.progression_skip_balancing
elif name.endswith("!USEFUL"):
# Same for above but with useful. This is typically used for Required Rings,
# as we don't want those locked in a barren dungeon
name = name.removesuffix("!USEFUL")
classification = ITEMS_DATA[name]["classification"]
if classification == ItemClassification.filler:
classification = ItemClassification.useful
else:
classification = ITEMS_DATA[name]["classification"]
ap_code = self.item_name_to_id[name]
# A few items become progression only in hard logic
progression_items_in_medium_logic = ["Expert's Ring", "Fist Ring", "Toss Ring", "Energy Ring"]
if self.options.logic_difficulty == "medium" and name in progression_items_in_medium_logic:
classification = ItemClassification.progression
return Item(name, classification, ap_code, self.player)
def build_item_pool_dict(self):
item_pool_dict = {}
filler_item_count = 0
remaining_rings = len({name for name, idata in ITEMS_DATA.items() if "ring" in idata}) - len(self.options.excluded_rings.value)
for loc_name, loc_data in LOCATIONS_DATA.items():
if "vanilla_item" not in loc_data:
#print("Can't create item from location '",loc_name ,"' because it doesn't have one")
continue
item_name = loc_data['vanilla_item']
if "randomized" in loc_data and loc_data["randomized"] is False:
item = self.create_item(item_name)
location = self.multiworld.get_location(loc_name, self.player)
location.place_locked_item(item)
#print("placing locked item '",loc_data['vanilla_item'] ,"' in '",loc_name ,"'")
continue
if not self.location_is_active(loc_name, loc_data):
#print("Can't create item '",loc_data['vanilla_item'] ,"' because '",loc_name ,"' is not active")
continue
if self.options.master_keys != OracleOfAgesMasterKeys.option_disabled and "Small Key" in item_name:
# Small Keys don't exist if Master Keys are set to replace them
filler_item_count += 1
continue
if self.options.master_keys == OracleOfAgesMasterKeys.option_all_dungeon_keys and "Boss Key" in item_name:
# Boss keys don't exist if Master Keys are set to replace them
filler_item_count += 1
continue
item_name = loc_data['vanilla_item']
if "Ring" in item_name:
if remaining_rings > 0:
item_name = "Random Ring"
remaining_rings -= 1
else:
filler_item_count += 1
continue
item_pool_dict[item_name] = item_pool_dict.get(item_name, 0) + 1
# If Master Keys are enabled, put one for every dungeon
if self.options.master_keys != OracleOfAgesMasterKeys.option_disabled:
for small_key_name in ITEM_GROUPS["Master Keys"]:
item_pool_dict[small_key_name] = 1
filler_item_count -= 1
# Add the required rings
ring_copy = sorted(self.options.required_rings.value.copy())
for _ in range(len(ring_copy)):
ring_name = f"{ring_copy.pop()}!USEFUL"
item_pool_dict[ring_name] = item_pool_dict.get(ring_name, 0) + 1
if item_pool_dict["Random Ring"] > 0:
# Take from set ring pool first
item_pool_dict["Random Ring"] -= 1
else:
# Take from filler after
filler_item_count -= 1
# Add as many filler items as required
for _ in range(filler_item_count):
random_filler_item = self.get_filler_item_name()
item_pool_dict[random_filler_item] = item_pool_dict.get(random_filler_item, 0) + 1
# Perform adjustments on the item pool
item_pool_adjustements = [
["Flute", self.options.animal_companion.current_key.title() + "'s Flute"], # Put a specific flute
["Gasha Seed", "Seed Satchel"], # Add a 3rd satchel that is usually obtained in linked games (99 seeds)
["Gasha Seed", "Bombs (10)"], # Add one more bomb compared to vanilla to reach 99 max bombs
["Gasha Seed", "Potion"], # Replace some Gasha Seed by 2 potions.
["Gasha Seed", "Potion"], # ^
["Gasha Seed", "Rupees (200)"], # and one by rupees
["Gasha Seed", "Progressive Sword"], # Need an additionnal sword to go to L3
]
for i, pair in enumerate(item_pool_adjustements):
original_name = pair[0]
replacement_name = pair[1]
item_pool_dict[original_name] -= 1
item_pool_dict[replacement_name] = item_pool_dict.get(replacement_name, 0) + 1
return item_pool_dict
def create_items(self):
item_pool_dict = self.build_item_pool_dict()
# Create items following the dictionary that was previously constructed
if (item_pool_dict.get("Random Ring")):
self.create_rings(item_pool_dict["Random Ring"])
del item_pool_dict["Random Ring"]
for item_name, quantity in item_pool_dict.items():
for i in range(quantity):
if ("Small Key" in item_name or "Master Key" in item_name) and not self.options.keysanity_small_keys:
self.dungeon_items.append(self.create_item(item_name))
elif "Boss Key" in item_name and not self.options.keysanity_boss_keys:
self.dungeon_items.append(self.create_item(item_name))
elif ("Compass" in item_name or "Dungeon Map" in item_name) and not self.options.keysanity_maps_compasses:
self.dungeon_items.append(self.create_item(item_name))
elif "Slate" in item_name and not self.options.keysanity_slates:
self.dungeon_items.append(self.create_item(item_name))
else:
self.multiworld.itempool.append(self.create_item(item_name))
def create_rings(self, amount):
# Get a subset of as many rings as needed
ring_names = [name for name, idata in ITEMS_DATA.items() if "ring" in idata]
# Remove excluded rings, and required rings because they'll be added later anyway
ring_names = [name for name in ring_names if name not in self.options.required_rings.value and name not in self.options.excluded_rings.value]
self.random.shuffle(ring_names)
del ring_names[amount:]
for ring_name in ring_names:
self.multiworld.itempool.append(self.create_item(ring_name))
self.random_rings_pool = ring_names
def get_pre_fill_items(self):
return self.pre_fill_items
def pre_fill(self) -> None:
self.pre_fill_seeds()
self.pre_fill_dungeon_items()
#self.debug_pre_fill("Dimitri's Flute", "Impa Gift")
def debug_pre_fill(self, i, l):
collection_state = self.multiworld.get_all_state(False)
locations = [loc for loc in self.multiworld.get_locations(self.player)
if l in loc.name]
item = [self.create_item(i)]
print(item)
print(locations)
fill_restrictive(self.multiworld, collection_state, locations, item, 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.
collection_state = self.multiworld.get_all_state(False)
D6_remaining_location = []
for i in range(0, 10):
# Build a list of locations in this dungeon
dungeon_location_names = [name for name, loc in LOCATIONS_DATA.items()
if "dungeon" in loc and loc["dungeon"] == i]
dungeon_locations = [loc for loc in self.multiworld.get_locations(self.player)
if loc.name in dungeon_location_names]
# Build a list of dungeon items that are "confined" (i.e. must be placed inside this dungeon)
# See `create_items` to see how `self.dungeon_items` is populated depending on current options.
confined_dungeon_items = [item for item in self.dungeon_items if item.name.endswith(f"({DUNGEON_NAMES[i]})") or (i == 8 and "Slate" in item.name)]
if len(confined_dungeon_items) == 0:
if i == 9 or i == 6:
D6_remaining_location += dungeon_locations
continue # This list might be empty with some keysanity options
for item in confined_dungeon_items:
collection_state.remove(item)
# Perform a prefill to place confined items inside locations of this dungeon
for attempts_remaining in range(2, -1, -1):
self.random.shuffle(dungeon_locations)
try:
fill_restrictive(self.multiworld, collection_state, dungeon_locations, confined_dungeon_items,
single_player_placement=True, lock=True, allow_excluded=True)
if i == 9 or i == 6:
D6_remaining_location += dungeon_locations
break
except FillError as exc:
if attempts_remaining == 0:
raise exc
logging.debug(f"Failed to shuffle dungeon items for player {self.player}. Retrying...")
# D6 specific item that can appear in both dungeon (the boss key)
d6CommonDungeon = "(Mermaid's Cave)"
confined_dungeon_items = [item for item in self.dungeon_items if item.name.endswith(d6CommonDungeon) ]
for item in confined_dungeon_items:
collection_state.remove(item)
# Preplace D6 Boss key
for attempts_remaining in range(2, -1, -1):
self.random.shuffle(D6_remaining_location)
try:
fill_restrictive(self.multiworld, collection_state, D6_remaining_location, confined_dungeon_items,
single_player_placement=True, lock=True, allow_excluded=True)
break
except FillError as exc:
if attempts_remaining == 0:
raise exc
logging.debug(f"Failed to shuffle dungeon items for player {self.player}. Retrying...")
def pre_fill_seeds(self) -> None:
def place_seed(seed_name: str, location_name: str):
seed_item = self.create_item(seed_name)
self.multiworld.get_location(location_name, self.player).place_locked_item(seed_item)
self.pre_fill_items.append(seed_item)
seeds_to_place = set([name for name in SEED_ITEMS if name != SEED_ITEMS[self.options.default_seed.value]])
manually_placed_trees = ["Lynna City: Seed Tree"]
trees_to_process = [name for name in TREES_TABLE if name not in manually_placed_trees]
# Place default seed type in Horon Village tree
place_seed(SEED_ITEMS[self.options.default_seed.value], "Lynna City: Seed Tree")
# Place remaining seeds on remaining trees
self.random.shuffle(trees_to_process)
for seed in seeds_to_place:
place_seed(seed, trees_to_process.pop())
while len(trees_to_process) != 0:
place_seed(self.random.choice(SEED_ITEMS), trees_to_process.pop())
def get_filler_item_name(self) -> str:
FILLER_ITEM_NAMES = [
"Rupees (1)", "Rupees (5)", "Rupees (5)", "Rupees (10)", "Rupees (10)",
"Rupees (20)", "Rupees (30)",
"Gasha Seed", "Gasha Seed",
"Potion"
]
item_name = self.random.choice(FILLER_ITEM_NAMES)
return item_name
def generate_output(self, output_directory: str):
patch = ooa_create_appp_patch(self)
rom_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}"
f"{patch.patch_file_ending}")
patch.write(rom_path)
return
def write_spoiler(self, spoiler_handle):
spoiler_handle.write(f"Apworld version : {VERSION}")
if self.options.shuffle_dungeons != "vanilla":
spoiler_handle.write(f"\nDungeon Entrances ({self.multiworld.player_name[self.player]}):\n")
for entrance, dungeon in self.dungeon_entrances.items():
spoiler_handle.write(f"\t- {entrance} --> {dungeon.replace('enter ', '')}\n")