From f9095768132982b50575f9420f754e624e3b5398 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 12 Oct 2022 13:28:32 -0500 Subject: [PATCH] core: Generic boss plando handler (#1044) * fix some blunders i made when implementing this * move generic functions to core class * move lttp specific stuff out and split up from_text a bit for more modularity * slightly optimize from_text call order * don't make changes on github apparently. reading hard * Metaclass Magic * do a check against the base class * copy paste strikes again * use option default instead of hardcoded "none". check locations and bosses aren't reused. * throw dupe location error for lttp * generic singularity support with a bool * forgot to enable it for lttp * better error handling * PlandoBosses: fix inheritance of singularity * Tests: PlandoBosses * fix case insensitive tests * Tests: cleanup PlandoBosses tests * f in the chat * oop * split location into a different variable Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> * pass the list of options as `option_list` Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --- Options.py | 119 ++++++++++++++++++++++++++- test/options/TestPlandoBosses.py | 136 +++++++++++++++++++++++++++++++ test/options/__init__.py | 0 worlds/alttp/Bosses.py | 2 +- worlds/alttp/Options.py | 113 ++++--------------------- 5 files changed, 271 insertions(+), 99 deletions(-) create mode 100644 test/options/TestPlandoBosses.py create mode 100644 test/options/__init__.py diff --git a/Options.py b/Options.py index 567ac8db..c243c8fe 100644 --- a/Options.py +++ b/Options.py @@ -427,7 +427,6 @@ class TextChoice(Choice): 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: @@ -467,6 +466,124 @@ class TextChoice(Choice): raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") +class BossMeta(AssembleOptions): + def __new__(mcs, name, bases, attrs): + if name != "PlandoBosses": + assert "bosses" in attrs, f"Please define valid bosses for {name}" + attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"])) + assert "locations" in attrs, f"Please define valid locations for {name}" + attrs["locations"] = frozenset((location.lower() for location in attrs["locations"])) + cls = super().__new__(mcs, name, bases, attrs) + assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}" + return cls + + +class PlandoBosses(TextChoice, metaclass=BossMeta): + """Generic boss shuffle option that supports plando. Format expected is + 'location1-boss1;location2-boss2;shuffle_mode'. + If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss, + which passes a plando boss and location. Check if the placement is valid for your game here.""" + bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]] + locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]] + + duplicate_bosses: bool = False + + @classmethod + def from_text(cls, text: str): + # set all of our text to lower case for name checking + text = text.lower() + 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) + return cls.get_shuffle_mode(options) + + @classmethod + def get_shuffle_mode(cls, option_list: typing.List[str]): + # find out what mode 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 option_list: + shuffle = random.choice(list(cls.options)) + option_list.remove("random") + options = ";".join(option_list) + f";{shuffle}" + boss_class = cls(options) + else: + for option in option_list: + if option in cls.options: + options = ";".join(option_list) + break + else: + if cls.duplicate_bosses and len(option_list) == 1: + if cls.valid_boss_name(option_list[0]): + # this doesn't exist in this class but it's a forced option for classes where this is called + options = option_list[0] + ";singularity" + else: + options = option_list[0] + f";{cls.name_lookup[cls.default]}" + else: + options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}" + boss_class = cls(options) + return boss_class + + @classmethod + def validate_plando_bosses(cls, options: typing.List[str]) -> None: + used_locations = [] + used_bosses = [] + for option in options: + # check if a shuffle mode was provided in the incorrect location + 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!") + elif "-" in option: + location, boss = option.split("-") + if location in used_locations: + raise ValueError(f"Duplicate Boss Location {location} not allowed.") + if not cls.duplicate_bosses and boss in used_bosses: + raise ValueError(f"Duplicate Boss {boss} not allowed.") + used_locations.append(location) + used_bosses.append(boss) + if not cls.valid_boss_name(boss): + raise ValueError(f"{boss.title()} is not a valid boss name.") + if not cls.valid_location_name(location): + raise ValueError(f"{location.title()} is not a valid boss location name.") + if not cls.can_place_boss(boss, location): + raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.") + else: + if cls.duplicate_bosses: + if not cls.valid_boss_name(option): + raise ValueError(f"{option} is not a valid boss name.") + else: + raise ValueError(f"{option.title()} is not formatted correctly.") + + @classmethod + def can_place_boss(cls, boss: str, location: str) -> bool: + raise NotImplementedError + + @classmethod + def valid_boss_name(cls, value: str) -> bool: + return value 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 Range(NumericOption): range_start = 0 range_end = 1 diff --git a/test/options/TestPlandoBosses.py b/test/options/TestPlandoBosses.py new file mode 100644 index 00000000..3c218b69 --- /dev/null +++ b/test/options/TestPlandoBosses.py @@ -0,0 +1,136 @@ +import unittest +import Generate +from Options import PlandoBosses + + +class SingleBosses(PlandoBosses): + bosses = {"B1", "B2"} + locations = {"L1", "L2"} + + option_vanilla = 0 + option_shuffle = 1 + + @staticmethod + def can_place_boss(boss: str, location: str) -> bool: + if boss == "b1" and location == "l1": + return False + return True + + +class MultiBosses(SingleBosses): + bosses = SingleBosses.bosses # explicit copy required + locations = SingleBosses.locations + duplicate_bosses = True + + option_singularity = 2 # required when duplicate_bosses = True + + +class TestPlandoBosses(unittest.TestCase): + def testCI(self): + """Bosses, locations and modes are supposed to be case-insensitive""" + self.assertEqual(MultiBosses.from_any("L1-B2").value, "l1-b2;vanilla") + self.assertEqual(MultiBosses.from_any("ShUfFlE").value, SingleBosses.option_shuffle) + + def testRandom(self): + """Validate random is random""" + import random + random.seed(0) + value1 = MultiBosses.from_any("random") + random.seed(0) + value2 = MultiBosses.from_text("random") + self.assertEqual(value1, value2) + for n in range(0, 100): + if MultiBosses.from_text("random") != value1: + break + else: + raise Exception("random is not random") + + def testShuffleMode(self): + """Test that simple modes (no Plando) work""" + self.assertEqual(MultiBosses.from_any("shuffle"), MultiBosses.option_shuffle) + self.assertNotEqual(MultiBosses.from_any("vanilla"), MultiBosses.option_shuffle) + + def testPlacement(self): + """Test that valid placements work and invalid placements fail""" + with self.assertRaises(ValueError): + MultiBosses.from_any("l1-b1") + MultiBosses.from_any("l1-b2;l2-b1") + + def testMixed(self): + """Test that shuffle is applied for remaining locations""" + self.assertIn("shuffle", MultiBosses.from_any("l1-b2;l2-b1;shuffle").value) + self.assertIn("vanilla", MultiBosses.from_any("l1-b2;l2-b1").value) + + def testUnknown(self): + """Test that unknown values throw exceptions""" + # unknown boss + with self.assertRaises(ValueError): + MultiBosses.from_any("l1-b0") + # unknown location + with self.assertRaises(ValueError): + MultiBosses.from_any("l0-b1") + # swapped boss-location + with self.assertRaises(ValueError): + MultiBosses.from_any("b2-b2") + # boss name in place of mode (no singularity) + with self.assertRaises(ValueError): + SingleBosses.from_any("b1") + with self.assertRaises(ValueError): + SingleBosses.from_any("l2-b2;b1") + # location name in place of mode + with self.assertRaises(ValueError): + MultiBosses.from_any("l1") + with self.assertRaises(ValueError): + MultiBosses.from_any("l2-b2;l1") + # mode name in place of location + with self.assertRaises(ValueError): + MultiBosses.from_any("shuffle-b2;vanilla") + with self.assertRaises(ValueError): + MultiBosses.from_any("shuffle-b2;l2-b2") + # mode name in place of boss + with self.assertRaises(ValueError): + MultiBosses.from_any("l2-shuffle;vanilla") + with self.assertRaises(ValueError): + MultiBosses.from_any("l1-shuffle;l2-b2") + + def testOrder(self): + """Can't use mode in random places""" + with self.assertRaises(ValueError): + MultiBosses.from_any("shuffle;l2-b2") + + def testDuplicateBoss(self): + """Can place the same boss twice""" + MultiBosses.from_any("l1-b2;l2-b2") + with self.assertRaises(ValueError): + SingleBosses.from_any("l1-b2;l2-b2") + + def testDuplicateLocation(self): + """Can't use the same location twice""" + with self.assertRaises(ValueError): + MultiBosses.from_any("l1-b2;l1-b2") + + def testSingularity(self): + """Test automatic singularity mode""" + self.assertIn(";singularity", MultiBosses.from_any("b2").value) + + def testPlandoSettings(self): + """Test that plando settings verification works""" + plandoed_string = "l1-b2;l2-b1" + mixed_string = "l1-b2;shuffle" + regular_string = "shuffle" + plandoed = MultiBosses.from_any(plandoed_string) + mixed = MultiBosses.from_any(mixed_string) + regular = MultiBosses.from_any(regular_string) + + # plando should work with boss plando + plandoed.verify(None, "Player", Generate.PlandoSettings.bosses) + self.assertTrue(plandoed.value.startswith(plandoed_string)) + # plando should fall back to default without boss plando + plandoed.verify(None, "Player", Generate.PlandoSettings.items) + self.assertEqual(plandoed, MultiBosses.option_vanilla) + # mixed should fall back to mode + mixed.verify(None, "Player", Generate.PlandoSettings.items) # should produce a warning and still work + self.assertEqual(mixed, MultiBosses.option_shuffle) + # mode stuff should just work + regular.verify(None, "Player", Generate.PlandoSettings.items) + self.assertEqual(regular, MultiBosses.option_shuffle) diff --git a/test/options/__init__.py b/test/options/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/worlds/alttp/Bosses.py b/worlds/alttp/Bosses.py index 1c381b9a..870b3c7c 100644 --- a/worlds/alttp/Bosses.py +++ b/worlds/alttp/Bosses.py @@ -3,7 +3,7 @@ from typing import Optional, Union, List, Tuple, Callable, Dict from BaseClasses import Boss from Fill import FillError -from .Options import Bosses +from .Options import LTTPBosses as Bosses def BossFactory(boss: str, player: int) -> Optional[Boss]: diff --git a/worlds/alttp/Options.py b/worlds/alttp/Options.py index b13d99f1..de6c479b 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, TextChoice +from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, TextChoice, PlandoBosses class Logic(Choice): @@ -138,7 +138,7 @@ class WorldState(Choice): option_inverted = 2 -class Bosses(TextChoice): +class LTTPBosses(PlandoBosses): """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. @@ -152,7 +152,9 @@ class Bosses(TextChoice): option_chaos = 3 option_singularity = 4 - bosses: set = { + duplicate_bosses = True + + bosses = { "Armos Knights", "Lanmolas", "Moldorm", @@ -165,7 +167,7 @@ class Bosses(TextChoice): "Trinexx", } - locations: set = { + locations = { "Ganons Tower Top", "Tower of Hera", "Skull Woods", @@ -181,99 +183,16 @@ class Bosses(TextChoice): "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}.") + def can_place_boss(cls, boss: str, location: str) -> bool: + from worlds.alttp.Bosses import can_place_boss + level = '' + words = location.split(" ") + if words[-1] in ("top", "middle", "bottom"): + level = words[-1] + location = " ".join(words[:-1]) + location = location.title().replace("Of", "of") + return can_place_boss(boss.title(), location, level) class Enemies(Choice): @@ -497,7 +416,7 @@ alttp_options: typing.Dict[str, type(Option)] = { "hints": Hints, "scams": Scams, "restrict_dungeon_item_on_boss": RestrictBossItem, - "boss_shuffle": Bosses, + "boss_shuffle": LTTPBosses, "pot_shuffle": PotShuffle, "enemy_shuffle": EnemyShuffle, "killable_thieves": KillableThieves,