diff --git a/BaseClasses.py b/BaseClasses.py index cea1d48e..7a7abc8b 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1422,7 +1422,6 @@ class Spoiler(): "f" in self.world.shop_shuffle[player])) outfile.write('Custom Potion Shop: %s\n' % 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 damage: %s\n' % self.world.enemy_damage[player]) outfile.write('Prize shuffle %s\n' % diff --git a/Generate.py b/Generate.py index d13a78b3..763471e9 100644 --- a/Generate.py +++ b/Generate.py @@ -23,7 +23,6 @@ from worlds.alttp.EntranceRandomizer import parse_arguments from Main import main as ERmain from BaseClasses import seeddigits, get_seed import Options -from worlds.alttp import Bosses from worlds.alttp.Text import TextTable from worlds.AutoWorld import AutoWorldRegister import copy @@ -337,19 +336,6 @@ def prefer_int(input_data: str) -> Union[str, int]: 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 = { 'ganon': 'ganon', 'crystals': 'crystals', @@ -456,42 +442,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict: return weights -def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str: - 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)): +def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings): if option_key in game_weights: try: 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: raise Exception(f"Error generating option {option_key} in {ret.game}") from e else: - if hasattr(player_option, "verify"): - player_option.verify(AutoWorldRegister.world_types[ret.game]) + player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options) else: 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: 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(): # 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): - handle_option(ret, game_weights, option_key, option) + handle_option(ret, game_weights, option_key, option, plando_options) if PlandoSettings.items in plando_options: ret.plando_items = game_weights.get("plando_items", []) 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) - boss_shuffle = get_choice_legacy('boss_shuffle', weights) - ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options) ret.enemy_damage = {None: 'default', 'default': 'default', diff --git a/Options.py b/Options.py index 56b54fe3..11ec46f2 100644 --- a/Options.py +++ b/Options.py @@ -40,6 +40,17 @@ class AssembleOptions(abc.ABCMeta): 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__ 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]: 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): # 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__ +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): range_start = 0 range_end = 1 @@ -512,7 +605,7 @@ class VerifyKeys: raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. " 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: new_value = type(self.value)() # empty container of whatever value is for item_name in self.value: @@ -737,8 +830,8 @@ class ItemLinks(OptionList): pool |= {item_name} return pool - def verify(self, world): - super(ItemLinks, self).verify(world) + def verify(self, world, player_name: str, plando_options) -> None: + super(ItemLinks, self).verify(world, player_name, plando_options) existing_links = set() for link in self.value: if link["name"] in existing_links: diff --git a/worlds/alttp/Bosses.py b/worlds/alttp/Bosses.py index 5ea7aba5..1c381b9a 100644 --- a/worlds/alttp/Bosses.py +++ b/worlds/alttp/Bosses.py @@ -1,8 +1,9 @@ import logging -from typing import Optional +from typing import Optional, Union, List, Tuple, Callable, Dict from BaseClasses import Boss from Fill import FillError +from .Options import Bosses 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) -def ArmosKnightsDefeatRule(state, player: int): +def ArmosKnightsDefeatRule(state, player: int) -> bool: # Magic amounts are probably a bit overkill return ( state.has_melee_weapon(player) or @@ -25,7 +26,7 @@ def ArmosKnightsDefeatRule(state, player: int): state.has('Red Boomerang', player)) -def LanmolasDefeatRule(state, player: int): +def LanmolasDefeatRule(state, player: int) -> bool: return ( state.has_melee_weapon(player) or state.has('Fire Rod', player) or @@ -35,16 +36,16 @@ def LanmolasDefeatRule(state, player: int): state.can_shoot_arrows(player)) -def MoldormDefeatRule(state, player: int): +def MoldormDefeatRule(state, player: int) -> bool: return state.has_melee_weapon(player) -def HelmasaurKingDefeatRule(state, player: int): +def HelmasaurKingDefeatRule(state, player: int) -> bool: # TODO: technically possible with the hammer 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): return False # 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)))) -def MothulaDefeatRule(state, player: int): +def MothulaDefeatRule(state, player: int) -> bool: return ( state.has_melee_weapon(player) 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) -def KholdstareDefeatRule(state, player: int): +def KholdstareDefeatRule(state, player: int) -> bool: return ( ( 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) -def TrinexxDefeatRule(state, player: int): +def TrinexxDefeatRule(state, player: int) -> bool: if not (state.has('Fire Rod', player) and state.has('Ice Rod', player)): return False 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)) -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) -def GanonDefeatRule(state, player: int): +def GanonDefeatRule(state, player: int) -> bool: if state.world.swordless[player]: return state.has('Hammer', 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) -boss_table = { +boss_table: Dict[str, Tuple[str, Optional[Callable]]] = { 'Armos Knights': ('Armos', ArmosKnightsDefeatRule), 'Lanmolas': ('Lanmola', LanmolasDefeatRule), 'Moldorm': ('Moldorm', MoldormDefeatRule), @@ -147,7 +148,7 @@ boss_table = { 'Agahnim2': ('Agahnim2', AgahnimDefeatRule) } -boss_location_table = [ +boss_location_table: List[Tuple[str, str]] = [ ('Ganons Tower', 'top'), ('Tower of Hera', 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: # blacklist approach 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 -restrictive_boss_locations = {} + +restrictive_boss_locations: Dict[Tuple[str, str], bool] = {} for location in boss_location_table: restrictive_boss_locations[location] = not all(can_place_boss(boss, *location) 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': location = 'Inverted Ganons Tower' logging.debug('Placing boss %s at %s', boss, location + (' (' + level + ')' if level else '')) 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 '') -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 + # 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])) + if not remaining_locations and not already_placed_bosses: + remaining_locations = boss_location_table.copy() + 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 placeable_bosses = [boss for boss in all_bosses if boss not in ['Agahnim', 'Agahnim2', 'Ganon']] - shuffle_mode = world.boss_shuffle[player] - already_placed_bosses = [] - 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 + if boss_shuffle == Bosses.option_basic or boss_shuffle == Bosses.option_full: + if boss_shuffle == Bosses.option_basic: # vanilla bosses shuffled bosses = placeable_bosses + ['Armos Knights', 'Lanmolas', 'Moldorm'] else: # all bosses present, the three duplicates chosen at random 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) world.random.shuffle(bosses) - for loc, level in boss_locations: + for loc, level in remaining_locations: for _ in range(len(bosses)): boss = bosses.pop() if can_place_boss(boss, loc, level): @@ -272,8 +289,8 @@ def place_bosses(world, player: int): place_boss(world, player, boss, loc, level) - elif shuffle_mode == "chaos": # all bosses chosen at random - for loc, level in boss_locations: + elif boss_shuffle == Bosses.option_chaos: # all bosses chosen at random + for loc, level in remaining_locations: try: boss = world.random.choice( [b for b in placeable_bosses if can_place_boss(b, loc, level)]) @@ -282,9 +299,9 @@ def place_bosses(world, player: int): else: place_boss(world, player, boss, loc, level) - elif shuffle_mode == "singularity": + elif boss_shuffle == Bosses.option_singularity: 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: # pick a boss to go into the remaining locations 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: raise Exception("Unfilled boss locations!") 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): - remainder = [] - placed_bosses = [] +def place_where_possible(world, player: int, boss: str, boss_locations) -> Tuple[List[Tuple[str, str]], List[str]]: + remainder: List[Tuple[str, str]] = [] + placed_bosses: List[str] = [] for loc, level in boss_locations: # place that boss where it can go if can_place_boss(boss, loc, level): diff --git a/worlds/alttp/Options.py b/worlds/alttp/Options.py index 184b8f3a..b13d99f1 100644 --- a/worlds/alttp/Options.py +++ b/worlds/alttp/Options.py @@ -1,7 +1,7 @@ import typing 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): @@ -138,13 +138,143 @@ class WorldState(Choice): option_inverted = 2 -class Bosses(Choice): - option_vanilla = 0 - option_simple = 1 +class Bosses(TextChoice): + """Shuffles bosses around to different locations. + 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_chaos = 3 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): option_vanilla = 0 @@ -164,8 +294,8 @@ class Progressive(Choice): class Swordless(Toggle): - """No swords. Curtains in Skull Woods and Agahnim\'s - Tower are removed, Agahnim\'s Tower barrier can be + """No swords. Curtains in Skull Woods and Agahnim's + Tower are removed, Agahnim's Tower barrier can be destroyed with hammer. Misery Mire and Turtle Rock can be opened without a sword. Hammer damages Ganon. Ether and Bombos Tablet can be activated with Hammer @@ -367,6 +497,7 @@ alttp_options: typing.Dict[str, type(Option)] = { "hints": Hints, "scams": Scams, "restrict_dungeon_item_on_boss": RestrictBossItem, + "boss_shuffle": Bosses, "pot_shuffle": PotShuffle, "enemy_shuffle": EnemyShuffle, "killable_thieves": KillableThieves, diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index cb66ac4f..2aeeec39 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -349,7 +349,7 @@ class ALTTPWorld(World): def use_enemizer(self): world = self.world 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.pot_shuffle[player] or world.bush_shuffle[player] or world.killable_thieves[player]) diff --git a/worlds/alttp/docs/plando_en.md b/worlds/alttp/docs/plando_en.md index 36322d99..af8cbfe1 100644 --- a/worlds/alttp/docs/plando_en.md +++ b/worlds/alttp/docs/plando_en.md @@ -26,10 +26,14 @@ - Example: `Trinexx` - 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. + - If no other options are provided this will follow normal singularity rules with that boss. - Boss Shuffle: - - Example: `simple` + - Example: `basic` - Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as 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 Arenas](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Bosses.py#L150)