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:
parent
9f684b3dc0
commit
f909576813
119
Options.py
119
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
|
||||
|
|
|
@ -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)
|
|
@ -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]:
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue