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>
This commit is contained in:
alwaysintreble 2022-10-12 13:28:32 -05:00 committed by GitHub
parent 9f684b3dc0
commit f909576813
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 271 additions and 99 deletions

View File

@ -427,7 +427,6 @@ class TextChoice(Choice):
assert isinstance(value, str) or isinstance(value, int), \ assert isinstance(value, str) or isinstance(value, int), \
f"{value} is not a valid option for {self.__class__.__name__}" f"{value} is not a valid option for {self.__class__.__name__}"
self.value = value self.value = value
super(TextChoice, self).__init__()
@property @property
def current_key(self) -> str: 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__}") 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): class Range(NumericOption):
range_start = 0 range_start = 0
range_end = 1 range_end = 1

View File

@ -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)

0
test/options/__init__.py Normal file
View File

View File

@ -3,7 +3,7 @@ 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 from .Options import LTTPBosses as Bosses
def BossFactory(boss: str, player: int) -> Optional[Boss]: def BossFactory(boss: str, player: int) -> Optional[Boss]:

View File

@ -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, TextChoice from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, TextChoice, PlandoBosses
class Logic(Choice): class Logic(Choice):
@ -138,7 +138,7 @@ class WorldState(Choice):
option_inverted = 2 option_inverted = 2
class Bosses(TextChoice): class LTTPBosses(PlandoBosses):
"""Shuffles bosses around to different locations. """Shuffles bosses around to different locations.
Basic will shuffle all bosses except Ganon and Agahnim anywhere they can be placed. 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. 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_chaos = 3
option_singularity = 4 option_singularity = 4
bosses: set = { duplicate_bosses = True
bosses = {
"Armos Knights", "Armos Knights",
"Lanmolas", "Lanmolas",
"Moldorm", "Moldorm",
@ -165,7 +167,7 @@ class Bosses(TextChoice):
"Trinexx", "Trinexx",
} }
locations: set = { locations = {
"Ganons Tower Top", "Ganons Tower Top",
"Tower of Hera", "Tower of Hera",
"Skull Woods", "Skull Woods",
@ -181,99 +183,16 @@ class Bosses(TextChoice):
"Ganons Tower Bottom" "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 @classmethod
def from_text(cls, text: str): def can_place_boss(cls, boss: str, location: str) -> bool:
import random from worlds.alttp.Bosses import can_place_boss
# 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 = '' level = ''
if not cls.valid_boss_name(boss): words = location.split(" ")
raise ValueError(f"{boss} is not a valid boss name for location {location}.") if words[-1] in ("top", "middle", "bottom"):
if not cls.valid_location_name(location): level = words[-1]
raise ValueError(f"{location} is not a valid boss location name.") location = " ".join(words[:-1])
if location.split(" ")[-1] in ("top", "middle", "bottom"):
location = location.split(" ")
level = location[-1]
location = " ".join(location[:-1])
location = location.title().replace("Of", "of") location = location.title().replace("Of", "of")
if not can_place_boss(boss.title(), location, level): return 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):
@ -497,7 +416,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, "boss_shuffle": LTTPBosses,
"pot_shuffle": PotShuffle, "pot_shuffle": PotShuffle,
"enemy_shuffle": EnemyShuffle, "enemy_shuffle": EnemyShuffle,
"killable_thieves": KillableThieves, "killable_thieves": KillableThieves,