core: new freetext and textchoice options (#728)
* add freetext and freetextchoice options * fix textchoice. create plando_bosses bool so worlds can check if boss plando is enabled * remove strange unneccessary \ escapes * lttp: rip boss plando out of core * fix broken text methods so they read the data correctly * revert `None` key in boss_shuffle_options. fix failing tests * lttp: rewrite boss plando * lttp: rewrite boss shuffle * add generic verification step and allow options to set a plando module * add default typing to plando_options set * use PlandoSettings intflag for lttp boss plando * fix plandosettings boss flag check * minor lttp init cleanup * make suggested changes. account for "random" existing within plando boss options * override eq operator * Please document me! * Forgot to mention it supports plando * remove auto_display_name * Throw warning alerting user to which shuffle is being used if plando is off. Set the remaining boss shuffle in init and boss placement cleanup * move the convoluted string matching to `from_text` * remove unneccessary text lowering and actually turn off plando option when it's disabled * typing * strong typing for verify method and reorder * typing is your friend * log warning correctly * 3.8 support :( * also list apparently * rip out old boss shuffle spoiler code * verification step for plando bosses and locations * update plando guide to reference new supported behavior * empty string is not `None`. remove unneccessary error throw * Fix bad ordering * validate boss_shuffle only contains a normal boss option at the end * get random choice from a list dummy * >:( Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> * minor textchoice cleanup Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
This commit is contained in:
parent
8d51205e8f
commit
332dde154f
|
@ -1422,7 +1422,6 @@ class Spoiler():
|
||||||
"f" in self.world.shop_shuffle[player]))
|
"f" in self.world.shop_shuffle[player]))
|
||||||
outfile.write('Custom Potion Shop: %s\n' %
|
outfile.write('Custom Potion Shop: %s\n' %
|
||||||
bool_to_text("w" in self.world.shop_shuffle[player]))
|
bool_to_text("w" in self.world.shop_shuffle[player]))
|
||||||
outfile.write('Boss shuffle: %s\n' % self.world.boss_shuffle[player])
|
|
||||||
outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
|
outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
|
||||||
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
|
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
|
||||||
outfile.write('Prize shuffle %s\n' %
|
outfile.write('Prize shuffle %s\n' %
|
||||||
|
|
60
Generate.py
60
Generate.py
|
@ -23,7 +23,6 @@ from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from BaseClasses import seeddigits, get_seed
|
from BaseClasses import seeddigits, get_seed
|
||||||
import Options
|
import Options
|
||||||
from worlds.alttp import Bosses
|
|
||||||
from worlds.alttp.Text import TextTable
|
from worlds.alttp.Text import TextTable
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
import copy
|
import copy
|
||||||
|
@ -337,19 +336,6 @@ def prefer_int(input_data: str) -> Union[str, int]:
|
||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
|
|
||||||
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
|
||||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
|
||||||
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
|
||||||
Bosses.boss_location_table}
|
|
||||||
|
|
||||||
boss_shuffle_options = {None: 'none',
|
|
||||||
'none': 'none',
|
|
||||||
'basic': 'basic',
|
|
||||||
'full': 'full',
|
|
||||||
'chaos': 'chaos',
|
|
||||||
'singularity': 'singularity'
|
|
||||||
}
|
|
||||||
|
|
||||||
goals = {
|
goals = {
|
||||||
'ganon': 'ganon',
|
'ganon': 'ganon',
|
||||||
'crystals': 'crystals',
|
'crystals': 'crystals',
|
||||||
|
@ -456,42 +442,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
|
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings):
|
||||||
if boss_shuffle in boss_shuffle_options:
|
|
||||||
return boss_shuffle_options[boss_shuffle]
|
|
||||||
elif PlandoSettings.bosses in plando_options:
|
|
||||||
options = boss_shuffle.lower().split(";")
|
|
||||||
remainder_shuffle = "none" # vanilla
|
|
||||||
bosses = []
|
|
||||||
for boss in options:
|
|
||||||
if boss in boss_shuffle_options:
|
|
||||||
remainder_shuffle = boss_shuffle_options[boss]
|
|
||||||
elif "-" in boss:
|
|
||||||
loc, boss_name = boss.split("-")
|
|
||||||
if boss_name not in available_boss_names:
|
|
||||||
raise ValueError(f"Unknown Boss name {boss_name}")
|
|
||||||
if loc not in available_boss_locations:
|
|
||||||
raise ValueError(f"Unknown Boss Location {loc}")
|
|
||||||
level = ''
|
|
||||||
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
|
||||||
# split off level
|
|
||||||
loc = loc.split(" ")
|
|
||||||
level = f" {loc[-1]}"
|
|
||||||
loc = " ".join(loc[:-1])
|
|
||||||
loc = loc.title().replace("Of", "of")
|
|
||||||
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
|
||||||
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
|
||||||
bosses.append(boss)
|
|
||||||
elif boss not in available_boss_names:
|
|
||||||
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
|
||||||
else:
|
|
||||||
bosses.append(boss)
|
|
||||||
return ";".join(bosses + [remainder_shuffle])
|
|
||||||
else:
|
|
||||||
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
|
||||||
|
|
||||||
|
|
||||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
|
|
||||||
if option_key in game_weights:
|
if option_key in game_weights:
|
||||||
try:
|
try:
|
||||||
if not option.supports_weighting:
|
if not option.supports_weighting:
|
||||||
|
@ -502,8 +453,7 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||||
else:
|
else:
|
||||||
if hasattr(player_option, "verify"):
|
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||||
player_option.verify(AutoWorldRegister.world_types[ret.game])
|
|
||||||
else:
|
else:
|
||||||
setattr(ret, option_key, option(option.default))
|
setattr(ret, option_key, option(option.default))
|
||||||
|
|
||||||
|
@ -549,11 +499,11 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
|
||||||
|
|
||||||
if ret.game in AutoWorldRegister.world_types:
|
if ret.game in AutoWorldRegister.world_types:
|
||||||
for option_key, option in world_type.option_definitions.items():
|
for option_key, option in world_type.option_definitions.items():
|
||||||
handle_option(ret, game_weights, option_key, option)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
for option_key, option in Options.per_game_common_options.items():
|
for option_key, option in Options.per_game_common_options.items():
|
||||||
# skip setting this option if already set from common_options, defaulting to root option
|
# skip setting this option if already set from common_options, defaulting to root option
|
||||||
if not (option_key in Options.common_options and option_key not in game_weights):
|
if not (option_key in Options.common_options and option_key not in game_weights):
|
||||||
handle_option(ret, game_weights, option_key, option)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
if PlandoSettings.items in plando_options:
|
if PlandoSettings.items in plando_options:
|
||||||
ret.plando_items = game_weights.get("plando_items", [])
|
ret.plando_items = game_weights.get("plando_items", [])
|
||||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||||
|
@ -636,8 +586,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||||
|
|
||||||
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||||
|
|
||||||
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
|
|
||||||
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
|
||||||
|
|
||||||
ret.enemy_damage = {None: 'default',
|
ret.enemy_damage = {None: 'default',
|
||||||
'default': 'default',
|
'default': 'default',
|
||||||
|
|
99
Options.py
99
Options.py
|
@ -40,6 +40,17 @@ class AssembleOptions(abc.ABCMeta):
|
||||||
|
|
||||||
options.update(aliases)
|
options.update(aliases)
|
||||||
|
|
||||||
|
if "verify" not in attrs:
|
||||||
|
# not overridden by class -> look up bases
|
||||||
|
verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f]
|
||||||
|
if len(verifiers) > 1: # verify multiple bases/mixins
|
||||||
|
def verify(self, *args, **kwargs) -> None:
|
||||||
|
for f in verifiers:
|
||||||
|
f(self, *args, **kwargs)
|
||||||
|
attrs["verify"] = verify
|
||||||
|
else:
|
||||||
|
assert verifiers, "class Option is supposed to implement def verify"
|
||||||
|
|
||||||
# auto-validate schema on __init__
|
# auto-validate schema on __init__
|
||||||
if "schema" in attrs.keys():
|
if "schema" in attrs.keys():
|
||||||
|
|
||||||
|
@ -117,6 +128,41 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||||
def from_any(cls, data: typing.Any) -> Option[T]:
|
def from_any(cls, data: typing.Any) -> Option[T]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from Generate import PlandoSettings
|
||||||
|
from worlds.AutoWorld import World
|
||||||
|
|
||||||
|
def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
def verify(self, *args, **kwargs) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FreeText(Option):
|
||||||
|
"""Text option that allows users to enter strings.
|
||||||
|
Needs to be validated by the world or option definition."""
|
||||||
|
|
||||||
|
def __init__(self, value: str):
|
||||||
|
assert isinstance(value, str), "value of FreeText must be a string"
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_key(self) -> str:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(cls, text: str) -> FreeText:
|
||||||
|
return cls(text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_any(cls, data: typing.Any) -> FreeText:
|
||||||
|
return cls.from_text(str(data))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_option_name(cls, value: T) -> str:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class NumericOption(Option[int], numbers.Integral):
|
class NumericOption(Option[int], numbers.Integral):
|
||||||
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
||||||
|
@ -373,6 +419,53 @@ class Choice(NumericOption):
|
||||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||||
|
|
||||||
|
|
||||||
|
class TextChoice(Choice):
|
||||||
|
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
|
||||||
|
|
||||||
|
def __init__(self, value: typing.Union[str, int]):
|
||||||
|
assert isinstance(value, str) or isinstance(value, int), \
|
||||||
|
f"{value} is not a valid option for {self.__class__.__name__}"
|
||||||
|
self.value = value
|
||||||
|
super(TextChoice, self).__init__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_key(self) -> str:
|
||||||
|
if isinstance(self.value, str):
|
||||||
|
return self.value
|
||||||
|
else:
|
||||||
|
return self.name_lookup[self.value]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(cls, text: str) -> TextChoice:
|
||||||
|
if text.lower() == "random": # chooses a random defined option but won't use any free text options
|
||||||
|
return cls(random.choice(list(cls.name_lookup)))
|
||||||
|
for option_name, value in cls.options.items():
|
||||||
|
if option_name.lower() == text.lower():
|
||||||
|
return cls(value)
|
||||||
|
return cls(text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_option_name(cls, value: T) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return cls.name_lookup[value]
|
||||||
|
|
||||||
|
def __eq__(self, other: typing.Any):
|
||||||
|
if isinstance(other, self.__class__):
|
||||||
|
return other.value == self.value
|
||||||
|
elif isinstance(other, str):
|
||||||
|
if other in self.options:
|
||||||
|
return other == self.current_key
|
||||||
|
return other == self.value
|
||||||
|
elif isinstance(other, int):
|
||||||
|
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
|
||||||
|
return other == self.value
|
||||||
|
elif isinstance(other, bool):
|
||||||
|
return other == bool(self.value)
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||||
|
|
||||||
|
|
||||||
class Range(NumericOption):
|
class Range(NumericOption):
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 1
|
range_end = 1
|
||||||
|
@ -512,7 +605,7 @@ class VerifyKeys:
|
||||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||||
f"Allowed keys: {cls.valid_keys}.")
|
f"Allowed keys: {cls.valid_keys}.")
|
||||||
|
|
||||||
def verify(self, world):
|
def verify(self, world, player_name: str, plando_options) -> None:
|
||||||
if self.convert_name_groups and self.verify_item_name:
|
if self.convert_name_groups and self.verify_item_name:
|
||||||
new_value = type(self.value)() # empty container of whatever value is
|
new_value = type(self.value)() # empty container of whatever value is
|
||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
|
@ -737,8 +830,8 @@ class ItemLinks(OptionList):
|
||||||
pool |= {item_name}
|
pool |= {item_name}
|
||||||
return pool
|
return pool
|
||||||
|
|
||||||
def verify(self, world):
|
def verify(self, world, player_name: str, plando_options) -> None:
|
||||||
super(ItemLinks, self).verify(world)
|
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||||
existing_links = set()
|
existing_links = set()
|
||||||
for link in self.value:
|
for link in self.value:
|
||||||
if link["name"] in existing_links:
|
if link["name"] in existing_links:
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional, Union, List, Tuple, Callable, Dict
|
||||||
|
|
||||||
from BaseClasses import Boss
|
from BaseClasses import Boss
|
||||||
from Fill import FillError
|
from Fill import FillError
|
||||||
|
from .Options import Bosses
|
||||||
|
|
||||||
|
|
||||||
def BossFactory(boss: str, player: int) -> Optional[Boss]:
|
def BossFactory(boss: str, player: int) -> Optional[Boss]:
|
||||||
|
@ -12,7 +13,7 @@ def BossFactory(boss: str, player: int) -> Optional[Boss]:
|
||||||
raise Exception('Unknown Boss: %s', boss)
|
raise Exception('Unknown Boss: %s', boss)
|
||||||
|
|
||||||
|
|
||||||
def ArmosKnightsDefeatRule(state, player: int):
|
def ArmosKnightsDefeatRule(state, player: int) -> bool:
|
||||||
# Magic amounts are probably a bit overkill
|
# Magic amounts are probably a bit overkill
|
||||||
return (
|
return (
|
||||||
state.has_melee_weapon(player) or
|
state.has_melee_weapon(player) or
|
||||||
|
@ -25,7 +26,7 @@ def ArmosKnightsDefeatRule(state, player: int):
|
||||||
state.has('Red Boomerang', player))
|
state.has('Red Boomerang', player))
|
||||||
|
|
||||||
|
|
||||||
def LanmolasDefeatRule(state, player: int):
|
def LanmolasDefeatRule(state, player: int) -> bool:
|
||||||
return (
|
return (
|
||||||
state.has_melee_weapon(player) or
|
state.has_melee_weapon(player) or
|
||||||
state.has('Fire Rod', player) or
|
state.has('Fire Rod', player) or
|
||||||
|
@ -35,16 +36,16 @@ def LanmolasDefeatRule(state, player: int):
|
||||||
state.can_shoot_arrows(player))
|
state.can_shoot_arrows(player))
|
||||||
|
|
||||||
|
|
||||||
def MoldormDefeatRule(state, player: int):
|
def MoldormDefeatRule(state, player: int) -> bool:
|
||||||
return state.has_melee_weapon(player)
|
return state.has_melee_weapon(player)
|
||||||
|
|
||||||
|
|
||||||
def HelmasaurKingDefeatRule(state, player: int):
|
def HelmasaurKingDefeatRule(state, player: int) -> bool:
|
||||||
# TODO: technically possible with the hammer
|
# TODO: technically possible with the hammer
|
||||||
return state.has_sword(player) or state.can_shoot_arrows(player)
|
return state.has_sword(player) or state.can_shoot_arrows(player)
|
||||||
|
|
||||||
|
|
||||||
def ArrghusDefeatRule(state, player: int):
|
def ArrghusDefeatRule(state, player: int) -> bool:
|
||||||
if not state.has('Hookshot', player):
|
if not state.has('Hookshot', player):
|
||||||
return False
|
return False
|
||||||
# TODO: ideally we would have a check for bow and silvers, which combined with the
|
# TODO: ideally we would have a check for bow and silvers, which combined with the
|
||||||
|
@ -58,7 +59,7 @@ def ArrghusDefeatRule(state, player: int):
|
||||||
(state.has('Ice Rod', player) and (state.can_shoot_arrows(player) or state.can_extend_magic(player, 16))))
|
(state.has('Ice Rod', player) and (state.can_shoot_arrows(player) or state.can_extend_magic(player, 16))))
|
||||||
|
|
||||||
|
|
||||||
def MothulaDefeatRule(state, player: int):
|
def MothulaDefeatRule(state, player: int) -> bool:
|
||||||
return (
|
return (
|
||||||
state.has_melee_weapon(player) or
|
state.has_melee_weapon(player) or
|
||||||
(state.has('Fire Rod', player) and state.can_extend_magic(player, 10)) or
|
(state.has('Fire Rod', player) and state.can_extend_magic(player, 10)) or
|
||||||
|
@ -70,11 +71,11 @@ def MothulaDefeatRule(state, player: int):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def BlindDefeatRule(state, player: int):
|
def BlindDefeatRule(state, player: int) -> bool:
|
||||||
return state.has_melee_weapon(player) or state.has('Cane of Somaria', player) or state.has('Cane of Byrna', player)
|
return state.has_melee_weapon(player) or state.has('Cane of Somaria', player) or state.has('Cane of Byrna', player)
|
||||||
|
|
||||||
|
|
||||||
def KholdstareDefeatRule(state, player: int):
|
def KholdstareDefeatRule(state, player: int) -> bool:
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
state.has('Fire Rod', player) or
|
state.has('Fire Rod', player) or
|
||||||
|
@ -96,11 +97,11 @@ def KholdstareDefeatRule(state, player: int):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def VitreousDefeatRule(state, player: int):
|
def VitreousDefeatRule(state, player: int) -> bool:
|
||||||
return state.can_shoot_arrows(player) or state.has_melee_weapon(player)
|
return state.can_shoot_arrows(player) or state.has_melee_weapon(player)
|
||||||
|
|
||||||
|
|
||||||
def TrinexxDefeatRule(state, player: int):
|
def TrinexxDefeatRule(state, player: int) -> bool:
|
||||||
if not (state.has('Fire Rod', player) and state.has('Ice Rod', player)):
|
if not (state.has('Fire Rod', player) and state.has('Ice Rod', player)):
|
||||||
return False
|
return False
|
||||||
return state.has('Hammer', player) or state.has('Tempered Sword', player) or state.has('Golden Sword', player) or \
|
return state.has('Hammer', player) or state.has('Tempered Sword', player) or state.has('Golden Sword', player) or \
|
||||||
|
@ -108,11 +109,11 @@ def TrinexxDefeatRule(state, player: int):
|
||||||
(state.has_sword(player) and state.can_extend_magic(player, 32))
|
(state.has_sword(player) and state.can_extend_magic(player, 32))
|
||||||
|
|
||||||
|
|
||||||
def AgahnimDefeatRule(state, player: int):
|
def AgahnimDefeatRule(state, player: int) -> bool:
|
||||||
return state.has_sword(player) or state.has('Hammer', player) or state.has('Bug Catching Net', player)
|
return state.has_sword(player) or state.has('Hammer', player) or state.has('Bug Catching Net', player)
|
||||||
|
|
||||||
|
|
||||||
def GanonDefeatRule(state, player: int):
|
def GanonDefeatRule(state, player: int) -> bool:
|
||||||
if state.world.swordless[player]:
|
if state.world.swordless[player]:
|
||||||
return state.has('Hammer', player) and \
|
return state.has('Hammer', player) and \
|
||||||
state.has_fire_source(player) and \
|
state.has_fire_source(player) and \
|
||||||
|
@ -132,7 +133,7 @@ def GanonDefeatRule(state, player: int):
|
||||||
return common and state.has('Silver Bow', player) and state.can_shoot_arrows(player)
|
return common and state.has('Silver Bow', player) and state.can_shoot_arrows(player)
|
||||||
|
|
||||||
|
|
||||||
boss_table = {
|
boss_table: Dict[str, Tuple[str, Optional[Callable]]] = {
|
||||||
'Armos Knights': ('Armos', ArmosKnightsDefeatRule),
|
'Armos Knights': ('Armos', ArmosKnightsDefeatRule),
|
||||||
'Lanmolas': ('Lanmola', LanmolasDefeatRule),
|
'Lanmolas': ('Lanmola', LanmolasDefeatRule),
|
||||||
'Moldorm': ('Moldorm', MoldormDefeatRule),
|
'Moldorm': ('Moldorm', MoldormDefeatRule),
|
||||||
|
@ -147,7 +148,7 @@ boss_table = {
|
||||||
'Agahnim2': ('Agahnim2', AgahnimDefeatRule)
|
'Agahnim2': ('Agahnim2', AgahnimDefeatRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
boss_location_table = [
|
boss_location_table: List[Tuple[str, str]] = [
|
||||||
('Ganons Tower', 'top'),
|
('Ganons Tower', 'top'),
|
||||||
('Tower of Hera', None),
|
('Tower of Hera', None),
|
||||||
('Skull Woods', None),
|
('Skull Woods', None),
|
||||||
|
@ -164,6 +165,34 @@ boss_location_table = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def place_plando_bosses(bosses: List[str], world, player: int) -> Tuple[List[str], List[Tuple[str, str]]]:
|
||||||
|
# Most to least restrictive order
|
||||||
|
boss_locations = boss_location_table.copy()
|
||||||
|
world.random.shuffle(boss_locations)
|
||||||
|
boss_locations.sort(key=lambda location: -int(restrictive_boss_locations[location]))
|
||||||
|
already_placed_bosses: List[str] = []
|
||||||
|
|
||||||
|
for boss in bosses:
|
||||||
|
if "-" in boss: # handle plando locations
|
||||||
|
loc, boss = boss.split("-")
|
||||||
|
boss = boss.title()
|
||||||
|
level: str = None
|
||||||
|
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
||||||
|
# split off level
|
||||||
|
loc = loc.split(" ")
|
||||||
|
level = loc[-1]
|
||||||
|
loc = " ".join(loc[:-1])
|
||||||
|
loc = loc.title().replace("Of", "of")
|
||||||
|
place_boss(world, player, boss, loc, level)
|
||||||
|
already_placed_bosses.append(boss)
|
||||||
|
boss_locations.remove((loc, level))
|
||||||
|
else: # boss chosen with no specified locations
|
||||||
|
boss = boss.title()
|
||||||
|
boss_locations, already_placed_bosses = place_where_possible(world, player, boss, boss_locations)
|
||||||
|
|
||||||
|
return already_placed_bosses, boss_locations
|
||||||
|
|
||||||
|
|
||||||
def can_place_boss(boss: str, dungeon_name: str, level: Optional[str] = None) -> bool:
|
def can_place_boss(boss: str, dungeon_name: str, level: Optional[str] = None) -> bool:
|
||||||
# blacklist approach
|
# blacklist approach
|
||||||
if boss in {"Agahnim", "Agahnim2", "Ganon"}:
|
if boss in {"Agahnim", "Agahnim2", "Ganon"}:
|
||||||
|
@ -187,62 +216,50 @@ def can_place_boss(boss: str, dungeon_name: str, level: Optional[str] = None) ->
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
restrictive_boss_locations = {}
|
|
||||||
|
restrictive_boss_locations: Dict[Tuple[str, str], bool] = {}
|
||||||
for location in boss_location_table:
|
for location in boss_location_table:
|
||||||
restrictive_boss_locations[location] = not all(can_place_boss(boss, *location)
|
restrictive_boss_locations[location] = not all(can_place_boss(boss, *location)
|
||||||
for boss in boss_table if not boss.startswith("Agahnim"))
|
for boss in boss_table if not boss.startswith("Agahnim"))
|
||||||
|
|
||||||
def place_boss(world, player: int, boss: str, location: str, level: Optional[str]):
|
|
||||||
|
def place_boss(world, player: int, boss: str, location: str, level: Optional[str]) -> None:
|
||||||
if location == 'Ganons Tower' and world.mode[player] == 'inverted':
|
if location == 'Ganons Tower' and world.mode[player] == 'inverted':
|
||||||
location = 'Inverted Ganons Tower'
|
location = 'Inverted Ganons Tower'
|
||||||
logging.debug('Placing boss %s at %s', boss, location + (' (' + level + ')' if level else ''))
|
logging.debug('Placing boss %s at %s', boss, location + (' (' + level + ')' if level else ''))
|
||||||
world.get_dungeon(location, player).bosses[level] = BossFactory(boss, player)
|
world.get_dungeon(location, player).bosses[level] = BossFactory(boss, player)
|
||||||
|
|
||||||
def format_boss_location(location, level):
|
|
||||||
|
def format_boss_location(location: str, level: str) -> str:
|
||||||
return location + (' (' + level + ')' if level else '')
|
return location + (' (' + level + ')' if level else '')
|
||||||
|
|
||||||
def place_bosses(world, player: int):
|
|
||||||
if world.boss_shuffle[player] == 'none':
|
def place_bosses(world, player: int) -> None:
|
||||||
|
# will either be an int or a lower case string with ';' between options
|
||||||
|
boss_shuffle: Union[str, int] = world.boss_shuffle[player].value
|
||||||
|
already_placed_bosses: List[str] = []
|
||||||
|
remaining_locations: List[Tuple[str, str]] = []
|
||||||
|
# handle plando
|
||||||
|
if isinstance(boss_shuffle, str):
|
||||||
|
# figure out our remaining mode, convert it to an int and remove it from plando_args
|
||||||
|
options = boss_shuffle.split(";")
|
||||||
|
boss_shuffle = Bosses.options[options.pop()]
|
||||||
|
# place our plando bosses
|
||||||
|
already_placed_bosses, remaining_locations = place_plando_bosses(options, world, player)
|
||||||
|
if boss_shuffle == Bosses.option_none: # vanilla boss locations
|
||||||
return
|
return
|
||||||
|
|
||||||
# Most to least restrictive order
|
# Most to least restrictive order
|
||||||
boss_locations = boss_location_table.copy()
|
if not remaining_locations and not already_placed_bosses:
|
||||||
world.random.shuffle(boss_locations)
|
remaining_locations = boss_location_table.copy()
|
||||||
boss_locations.sort(key= lambda location: -int(restrictive_boss_locations[location]))
|
world.random.shuffle(remaining_locations)
|
||||||
|
remaining_locations.sort(key=lambda location: -int(restrictive_boss_locations[location]))
|
||||||
|
|
||||||
all_bosses = sorted(boss_table.keys()) # sorted to be deterministic on older pythons
|
all_bosses = sorted(boss_table.keys()) # sorted to be deterministic on older pythons
|
||||||
placeable_bosses = [boss for boss in all_bosses if boss not in ['Agahnim', 'Agahnim2', 'Ganon']]
|
placeable_bosses = [boss for boss in all_bosses if boss not in ['Agahnim', 'Agahnim2', 'Ganon']]
|
||||||
|
|
||||||
shuffle_mode = world.boss_shuffle[player]
|
if boss_shuffle == Bosses.option_basic or boss_shuffle == Bosses.option_full:
|
||||||
already_placed_bosses = []
|
if boss_shuffle == Bosses.option_basic: # vanilla bosses shuffled
|
||||||
if ";" in shuffle_mode:
|
|
||||||
bosses = shuffle_mode.split(";")
|
|
||||||
shuffle_mode = bosses.pop()
|
|
||||||
for boss in bosses:
|
|
||||||
if "-" in boss:
|
|
||||||
loc, boss = boss.split("-")
|
|
||||||
boss = boss.title()
|
|
||||||
level = None
|
|
||||||
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
|
||||||
# split off level
|
|
||||||
loc = loc.split(" ")
|
|
||||||
level = loc[-1]
|
|
||||||
loc = " ".join(loc[:-1])
|
|
||||||
loc = loc.title().replace("Of", "of")
|
|
||||||
if can_place_boss(boss, loc, level) and (loc, level) in boss_locations:
|
|
||||||
place_boss(world, player, boss, loc, level)
|
|
||||||
already_placed_bosses.append(boss)
|
|
||||||
boss_locations.remove((loc, level))
|
|
||||||
else:
|
|
||||||
raise Exception(f"Cannot place {boss} at {format_boss_location(loc, level)} for player {player}.")
|
|
||||||
else:
|
|
||||||
boss = boss.title()
|
|
||||||
boss_locations, already_placed_bosses = place_where_possible(world, player, boss, boss_locations)
|
|
||||||
|
|
||||||
if shuffle_mode == "none":
|
|
||||||
return # vanilla bosses come pre-placed
|
|
||||||
|
|
||||||
if shuffle_mode in ["basic", "full"]:
|
|
||||||
if world.boss_shuffle[player] == "basic": # vanilla bosses shuffled
|
|
||||||
bosses = placeable_bosses + ['Armos Knights', 'Lanmolas', 'Moldorm']
|
bosses = placeable_bosses + ['Armos Knights', 'Lanmolas', 'Moldorm']
|
||||||
else: # all bosses present, the three duplicates chosen at random
|
else: # all bosses present, the three duplicates chosen at random
|
||||||
bosses = placeable_bosses + world.random.sample(placeable_bosses, 3)
|
bosses = placeable_bosses + world.random.sample(placeable_bosses, 3)
|
||||||
|
@ -258,7 +275,7 @@ def place_bosses(world, player: int):
|
||||||
logging.debug('Bosses chosen %s', bosses)
|
logging.debug('Bosses chosen %s', bosses)
|
||||||
|
|
||||||
world.random.shuffle(bosses)
|
world.random.shuffle(bosses)
|
||||||
for loc, level in boss_locations:
|
for loc, level in remaining_locations:
|
||||||
for _ in range(len(bosses)):
|
for _ in range(len(bosses)):
|
||||||
boss = bosses.pop()
|
boss = bosses.pop()
|
||||||
if can_place_boss(boss, loc, level):
|
if can_place_boss(boss, loc, level):
|
||||||
|
@ -272,8 +289,8 @@ def place_bosses(world, player: int):
|
||||||
|
|
||||||
place_boss(world, player, boss, loc, level)
|
place_boss(world, player, boss, loc, level)
|
||||||
|
|
||||||
elif shuffle_mode == "chaos": # all bosses chosen at random
|
elif boss_shuffle == Bosses.option_chaos: # all bosses chosen at random
|
||||||
for loc, level in boss_locations:
|
for loc, level in remaining_locations:
|
||||||
try:
|
try:
|
||||||
boss = world.random.choice(
|
boss = world.random.choice(
|
||||||
[b for b in placeable_bosses if can_place_boss(b, loc, level)])
|
[b for b in placeable_bosses if can_place_boss(b, loc, level)])
|
||||||
|
@ -282,9 +299,9 @@ def place_bosses(world, player: int):
|
||||||
else:
|
else:
|
||||||
place_boss(world, player, boss, loc, level)
|
place_boss(world, player, boss, loc, level)
|
||||||
|
|
||||||
elif shuffle_mode == "singularity":
|
elif boss_shuffle == Bosses.option_singularity:
|
||||||
primary_boss = world.random.choice(placeable_bosses)
|
primary_boss = world.random.choice(placeable_bosses)
|
||||||
remaining_boss_locations, _ = place_where_possible(world, player, primary_boss, boss_locations)
|
remaining_boss_locations, _ = place_where_possible(world, player, primary_boss, remaining_locations)
|
||||||
if remaining_boss_locations:
|
if remaining_boss_locations:
|
||||||
# pick a boss to go into the remaining locations
|
# pick a boss to go into the remaining locations
|
||||||
remaining_boss = world.random.choice([boss for boss in placeable_bosses if all(
|
remaining_boss = world.random.choice([boss for boss in placeable_bosses if all(
|
||||||
|
@ -293,12 +310,12 @@ def place_bosses(world, player: int):
|
||||||
if remaining_boss_locations:
|
if remaining_boss_locations:
|
||||||
raise Exception("Unfilled boss locations!")
|
raise Exception("Unfilled boss locations!")
|
||||||
else:
|
else:
|
||||||
raise FillError(f"Could not find boss shuffle mode {shuffle_mode}")
|
raise FillError(f"Could not find boss shuffle mode {boss_shuffle}")
|
||||||
|
|
||||||
|
|
||||||
def place_where_possible(world, player: int, boss: str, boss_locations):
|
def place_where_possible(world, player: int, boss: str, boss_locations) -> Tuple[List[Tuple[str, str]], List[str]]:
|
||||||
remainder = []
|
remainder: List[Tuple[str, str]] = []
|
||||||
placed_bosses = []
|
placed_bosses: List[str] = []
|
||||||
for loc, level in boss_locations:
|
for loc, level in boss_locations:
|
||||||
# place that boss where it can go
|
# place that boss where it can go
|
||||||
if can_place_boss(boss, loc, level):
|
if can_place_boss(boss, loc, level):
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from BaseClasses import MultiWorld
|
from BaseClasses import MultiWorld
|
||||||
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink
|
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, TextChoice
|
||||||
|
|
||||||
|
|
||||||
class Logic(Choice):
|
class Logic(Choice):
|
||||||
|
@ -138,13 +138,143 @@ class WorldState(Choice):
|
||||||
option_inverted = 2
|
option_inverted = 2
|
||||||
|
|
||||||
|
|
||||||
class Bosses(Choice):
|
class Bosses(TextChoice):
|
||||||
option_vanilla = 0
|
"""Shuffles bosses around to different locations.
|
||||||
option_simple = 1
|
Basic will shuffle all bosses except Ganon and Agahnim anywhere they can be placed.
|
||||||
|
Full chooses 3 bosses at random to be placed twice instead of Lanmolas, Moldorm, and Helmasaur.
|
||||||
|
Chaos allows any boss to appear any number of times.
|
||||||
|
Singularity places a single boss in as many places as possible, and a second boss in any remaining locations.
|
||||||
|
Supports plando placement. Formatting here: https://archipelago.gg/tutorial/A%20Link%20to%20the%20Past/plando/en"""
|
||||||
|
display_name = "Boss Shuffle"
|
||||||
|
option_none = 0
|
||||||
|
option_basic = 1
|
||||||
option_full = 2
|
option_full = 2
|
||||||
option_chaos = 3
|
option_chaos = 3
|
||||||
option_singularity = 4
|
option_singularity = 4
|
||||||
|
|
||||||
|
bosses: set = {
|
||||||
|
"Armos Knights",
|
||||||
|
"Lanmolas",
|
||||||
|
"Moldorm",
|
||||||
|
"Helmasaur King",
|
||||||
|
"Arrghus",
|
||||||
|
"Mothula",
|
||||||
|
"Blind",
|
||||||
|
"Kholdstare",
|
||||||
|
"Vitreous",
|
||||||
|
"Trinexx",
|
||||||
|
}
|
||||||
|
|
||||||
|
locations: set = {
|
||||||
|
"Ganons Tower Top",
|
||||||
|
"Tower of Hera",
|
||||||
|
"Skull Woods",
|
||||||
|
"Ganons Tower Middle",
|
||||||
|
"Eastern Palace",
|
||||||
|
"Desert Palace",
|
||||||
|
"Palace of Darkness",
|
||||||
|
"Swamp Palace",
|
||||||
|
"Thieves Town",
|
||||||
|
"Ice Palace",
|
||||||
|
"Misery Mire",
|
||||||
|
"Turtle Rock",
|
||||||
|
"Ganons Tower Bottom"
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, value: typing.Union[str, int]):
|
||||||
|
assert isinstance(value, str) or isinstance(value, int), \
|
||||||
|
f"{value} is not a valid option for {self.__class__.__name__}"
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(cls, text: str):
|
||||||
|
import random
|
||||||
|
# set all of our text to lower case for name checking
|
||||||
|
text = text.lower()
|
||||||
|
cls.bosses = {boss_name.lower() for boss_name in cls.bosses}
|
||||||
|
cls.locations = {boss_location.lower() for boss_location in cls.locations}
|
||||||
|
if text == "random":
|
||||||
|
return cls(random.choice(list(cls.options.values())))
|
||||||
|
for option_name, value in cls.options.items():
|
||||||
|
if option_name == text:
|
||||||
|
return cls(value)
|
||||||
|
options = text.split(";")
|
||||||
|
|
||||||
|
# since plando exists in the option verify the plando values given are valid
|
||||||
|
cls.validate_plando_bosses(options)
|
||||||
|
|
||||||
|
# find out what type of boss shuffle we should use for placing bosses after plando
|
||||||
|
# and add as a string to look nice in the spoiler
|
||||||
|
if "random" in options:
|
||||||
|
shuffle = random.choice(list(cls.options))
|
||||||
|
options.remove("random")
|
||||||
|
options = ";".join(options) + ";" + shuffle
|
||||||
|
boss_class = cls(options)
|
||||||
|
else:
|
||||||
|
for option in options:
|
||||||
|
if option in cls.options:
|
||||||
|
boss_class = cls(";".join(options))
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if len(options) == 1:
|
||||||
|
if cls.valid_boss_name(options[0]):
|
||||||
|
options = options[0] + ";singularity"
|
||||||
|
boss_class = cls(options)
|
||||||
|
else:
|
||||||
|
options = options[0] + ";none"
|
||||||
|
boss_class = cls(options)
|
||||||
|
else:
|
||||||
|
options = ";".join(options) + ";none"
|
||||||
|
boss_class = cls(options)
|
||||||
|
return boss_class
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
|
||||||
|
from .Bosses import can_place_boss, format_boss_location
|
||||||
|
for option in options:
|
||||||
|
if option == "random" or option in cls.options:
|
||||||
|
if option != options[-1]:
|
||||||
|
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
|
||||||
|
continue
|
||||||
|
if "-" in option:
|
||||||
|
location, boss = option.split("-")
|
||||||
|
level = ''
|
||||||
|
if not cls.valid_boss_name(boss):
|
||||||
|
raise ValueError(f"{boss} is not a valid boss name for location {location}.")
|
||||||
|
if not cls.valid_location_name(location):
|
||||||
|
raise ValueError(f"{location} is not a valid boss location name.")
|
||||||
|
if location.split(" ")[-1] in ("top", "middle", "bottom"):
|
||||||
|
location = location.split(" ")
|
||||||
|
level = location[-1]
|
||||||
|
location = " ".join(location[:-1])
|
||||||
|
location = location.title().replace("Of", "of")
|
||||||
|
if not can_place_boss(boss.title(), location, level):
|
||||||
|
raise ValueError(f"{format_boss_location(location, level)} "
|
||||||
|
f"is not a valid location for {boss.title()}.")
|
||||||
|
else:
|
||||||
|
if not cls.valid_boss_name(option):
|
||||||
|
raise ValueError(f"{option} is not a valid boss name.")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def valid_boss_name(cls, value: str) -> bool:
|
||||||
|
return value.lower() in cls.bosses
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def valid_location_name(cls, value: str) -> bool:
|
||||||
|
return value in cls.locations
|
||||||
|
|
||||||
|
def verify(self, world, player_name: str, plando_options) -> None:
|
||||||
|
if isinstance(self.value, int):
|
||||||
|
return
|
||||||
|
from Generate import PlandoSettings
|
||||||
|
if not(PlandoSettings.bosses & plando_options):
|
||||||
|
import logging
|
||||||
|
# plando is disabled but plando options were given so pull the option and change it to an int
|
||||||
|
option = self.value.split(";")[-1]
|
||||||
|
self.value = self.options[option]
|
||||||
|
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
|
||||||
|
f"boss shuffle will be used for player {player_name}.")
|
||||||
|
|
||||||
|
|
||||||
class Enemies(Choice):
|
class Enemies(Choice):
|
||||||
option_vanilla = 0
|
option_vanilla = 0
|
||||||
|
@ -164,8 +294,8 @@ class Progressive(Choice):
|
||||||
|
|
||||||
|
|
||||||
class Swordless(Toggle):
|
class Swordless(Toggle):
|
||||||
"""No swords. Curtains in Skull Woods and Agahnim\'s
|
"""No swords. Curtains in Skull Woods and Agahnim's
|
||||||
Tower are removed, Agahnim\'s Tower barrier can be
|
Tower are removed, Agahnim's Tower barrier can be
|
||||||
destroyed with hammer. Misery Mire and Turtle Rock
|
destroyed with hammer. Misery Mire and Turtle Rock
|
||||||
can be opened without a sword. Hammer damages Ganon.
|
can be opened without a sword. Hammer damages Ganon.
|
||||||
Ether and Bombos Tablet can be activated with Hammer
|
Ether and Bombos Tablet can be activated with Hammer
|
||||||
|
@ -367,6 +497,7 @@ alttp_options: typing.Dict[str, type(Option)] = {
|
||||||
"hints": Hints,
|
"hints": Hints,
|
||||||
"scams": Scams,
|
"scams": Scams,
|
||||||
"restrict_dungeon_item_on_boss": RestrictBossItem,
|
"restrict_dungeon_item_on_boss": RestrictBossItem,
|
||||||
|
"boss_shuffle": Bosses,
|
||||||
"pot_shuffle": PotShuffle,
|
"pot_shuffle": PotShuffle,
|
||||||
"enemy_shuffle": EnemyShuffle,
|
"enemy_shuffle": EnemyShuffle,
|
||||||
"killable_thieves": KillableThieves,
|
"killable_thieves": KillableThieves,
|
||||||
|
|
|
@ -349,7 +349,7 @@ class ALTTPWorld(World):
|
||||||
def use_enemizer(self):
|
def use_enemizer(self):
|
||||||
world = self.world
|
world = self.world
|
||||||
player = self.player
|
player = self.player
|
||||||
return (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player]
|
return (world.boss_shuffle[player] or world.enemy_shuffle[player]
|
||||||
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
|
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
|
||||||
or world.pot_shuffle[player] or world.bush_shuffle[player]
|
or world.pot_shuffle[player] or world.bush_shuffle[player]
|
||||||
or world.killable_thieves[player])
|
or world.killable_thieves[player])
|
||||||
|
|
|
@ -26,10 +26,14 @@
|
||||||
- Example: `Trinexx`
|
- Example: `Trinexx`
|
||||||
- Takes a particular boss and places that boss in any remaining slots in which this boss can function.
|
- Takes a particular boss and places that boss in any remaining slots in which this boss can function.
|
||||||
- In this example, it would fill Desert Palace, but not Tower of Hera.
|
- In this example, it would fill Desert Palace, but not Tower of Hera.
|
||||||
|
- If no other options are provided this will follow normal singularity rules with that boss.
|
||||||
- Boss Shuffle:
|
- Boss Shuffle:
|
||||||
- Example: `simple`
|
- Example: `basic`
|
||||||
- Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as
|
- Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as
|
||||||
a last instruction.
|
a last instruction.
|
||||||
|
- Supports `random` which will choose a random option from the normal choices.
|
||||||
|
- If one is not supplied any remaining locations will be unshuffled unless a single specific boss is
|
||||||
|
supplied in which case it will use singularity as noted above.
|
||||||
- [Available Bosses](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Bosses.py#L135)
|
- [Available Bosses](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Bosses.py#L135)
|
||||||
- [Available Arenas](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Bosses.py#L150)
|
- [Available Arenas](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Bosses.py#L150)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue