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), \
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

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

View File

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