From e00b5a7d17715c28e3d811434a9478df6bbaaabd Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Fri, 12 Jan 2024 01:07:40 +0100 Subject: [PATCH] SoE: use new AP API and naming and make APworld (#2701) * SoE: new file naming also fixes test base deprecation * SoE: use options_dataclass * SoE: moar typing * SoE: no more multiworld.random * SoE: replace LogicMixin by SoEPlayerLogic object * SoE: add test that rocket parts always exist * SoE: Even moar typing * SoE: can haz apworld now * SoE: pep up test naming * SoE: use self.options for trap chances * SoE: remove unused import with outdated comment * SoE: move flag and trap extraction to dataclass as suggested by beauxq * SoE: test trap option parsing and item generation --- setup.py | 1 - worlds/soe/Logic.py | 70 ---------- worlds/soe/__init__.py | 166 +++++++++++------------- worlds/soe/logic.py | 85 ++++++++++++ worlds/soe/{Options.py => options.py} | 97 ++++++++------ worlds/soe/{Patch.py => patch.py} | 6 +- worlds/soe/test/__init__.py | 13 +- worlds/soe/test/test_access.py | 4 +- worlds/soe/test/test_goal.py | 12 +- worlds/soe/test/test_oob.py | 4 +- worlds/soe/test/test_sequence_breaks.py | 4 +- worlds/soe/test/test_traps.py | 55 ++++++++ 12 files changed, 298 insertions(+), 219 deletions(-) delete mode 100644 worlds/soe/Logic.py create mode 100644 worlds/soe/logic.py rename worlds/soe/{Options.py => options.py} (71%) rename worlds/soe/{Patch.py => patch.py} (86%) create mode 100644 worlds/soe/test/test_traps.py diff --git a/setup.py b/setup.py index c864a8cc9d39..39a93e938540 100644 --- a/setup.py +++ b/setup.py @@ -76,7 +76,6 @@ "Ocarina of Time", "Overcooked! 2", "Raft", - "Secret of Evermore", "Slay the Spire", "Sudoku", "Super Mario 64", diff --git a/worlds/soe/Logic.py b/worlds/soe/Logic.py deleted file mode 100644 index fe5339c955b9..000000000000 --- a/worlds/soe/Logic.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Protocol, Set - -from BaseClasses import MultiWorld -from worlds.AutoWorld import LogicMixin -from . import pyevermizer -from .Options import EnergyCore, OutOfBounds, SequenceBreaks - -# TODO: Options may preset certain progress steps (i.e. P_ROCK_SKIP), set in generate_early? - -# TODO: resolve/flatten/expand rules to get rid of recursion below where possible -# Logic.rules are all rules including locations, excluding those with no progress (i.e. locations that only drop items) -rules = [rule for rule in pyevermizer.get_logic() if len(rule.provides) > 0] -# Logic.items are all items and extra items excluding non-progression items and duplicates -item_names: Set[str] = set() -items = [item for item in filter(lambda item: item.progression, pyevermizer.get_items() + pyevermizer.get_extra_items()) - if item.name not in item_names and not item_names.add(item.name)] - - -class LogicProtocol(Protocol): - def has(self, name: str, player: int) -> bool: ... - def count(self, name: str, player: int) -> int: ... - def soe_has(self, progress: int, world: MultiWorld, player: int, count: int) -> bool: ... - def _soe_count(self, progress: int, world: MultiWorld, player: int, max_count: int) -> int: ... - - -# when this module is loaded, this mixin will extend BaseClasses.CollectionState -class SecretOfEvermoreLogic(LogicMixin): - def _soe_count(self: LogicProtocol, progress: int, world: MultiWorld, player: int, max_count: int = 0) -> int: - """ - Returns reached count of one of evermizer's progress steps based on collected items. - i.e. returns 0-3 for P_DE based on items providing CHECK_BOSS,DIAMOND_EYE_DROP - """ - n = 0 - for item in items: - for pvd in item.provides: - if pvd[1] == progress: - if self.has(item.name, player): - n += self.count(item.name, player) * pvd[0] - if n >= max_count > 0: - return n - for rule in rules: - for pvd in rule.provides: - if pvd[1] == progress and pvd[0] > 0: - has = True - for req in rule.requires: - if not self.soe_has(req[1], world, player, req[0]): - has = False - break - if has: - n += pvd[0] - if n >= max_count > 0: - return n - return n - - def soe_has(self: LogicProtocol, progress: int, world: MultiWorld, player: int, count: int = 1) -> bool: - """ - Returns True if count of one of evermizer's progress steps is reached based on collected items. i.e. 2 * P_DE - """ - if progress == pyevermizer.P_ENERGY_CORE: # logic is shared between worlds, so we override in the call - w = world.worlds[player] - if w.energy_core == EnergyCore.option_fragments: - progress = pyevermizer.P_CORE_FRAGMENT - count = w.required_fragments - elif progress == pyevermizer.P_ALLOW_OOB: - if world.worlds[player].out_of_bounds == OutOfBounds.option_logic: - return True - elif progress == pyevermizer.P_ALLOW_SEQUENCE_BREAKS: - if world.worlds[player].sequence_breaks == SequenceBreaks.option_logic: - return True - return self._soe_count(progress, world, player, count) >= count diff --git a/worlds/soe/__init__.py b/worlds/soe/__init__.py index d02a8d02ee97..b431e471e2e9 100644 --- a/worlds/soe/__init__.py +++ b/worlds/soe/__init__.py @@ -4,18 +4,20 @@ import threading import typing +# from . import pyevermizer # as part of the source tree +import pyevermizer # from package + import settings +from BaseClasses import Item, ItemClassification, Location, LocationProgressType, Region, Tutorial +from Utils import output_path from worlds.AutoWorld import WebWorld, World from worlds.generic.Rules import add_item_rule, set_rule -from BaseClasses import Entrance, Item, ItemClassification, Location, LocationProgressType, Region, Tutorial -from Utils import output_path - -import pyevermizer # from package -# from . import pyevermizer # as part of the source tree +from .logic import SoEPlayerLogic +from .options import AvailableFragments, Difficulty, EnergyCore, RequiredFragments, SoEOptions, TrapChance +from .patch import SoEDeltaPatch, get_base_rom_path -from . import Logic # load logic mixin -from .Options import soe_options, Difficulty, EnergyCore, RequiredFragments, AvailableFragments -from .Patch import SoEDeltaPatch, get_base_rom_path +if typing.TYPE_CHECKING: + from BaseClasses import MultiWorld, CollectionState """ In evermizer: @@ -24,17 +26,17 @@ For most items this is their vanilla location (i.e. CHECK_GOURD, number). Items have `provides`, which give the actual progression -instead of providing multiple events per item, we iterate through them in Logic.py +instead of providing multiple events per item, we iterate through them in logic.py e.g. Found any weapon Locations have `requires` and `provides`. Requirements have to be converted to (access) rules for AP e.g. Chest locked behind having a weapon -Provides could be events, but instead we iterate through the entire logic in Logic.py +Provides could be events, but instead we iterate through the entire logic in logic.py e.g. NPC available after fighting a Boss Rules are special locations that don't have a physical location -instead of implementing virtual locations and virtual items, we simply use them in Logic.py +instead of implementing virtual locations and virtual items, we simply use them in logic.py e.g. 2DEs+Wheel+Gauge = Rocket Rules and Locations live on the same logic tree returned by pyevermizer.get_logic() @@ -84,8 +86,8 @@ ) -def _match_item_name(item, substr: str) -> bool: - sub = item.name.split(' ', 1)[1] if item.name[0].isdigit() else item.name +def _match_item_name(item: pyevermizer.Item, substr: str) -> bool: + sub: str = item.name.split(' ', 1)[1] if item.name[0].isdigit() else item.name return sub == substr or sub == substr+'s' @@ -158,8 +160,9 @@ class SoEWorld(World): Secret of Evermore is a SNES action RPG. You learn alchemy spells, fight bosses and gather rocket parts to visit a space station where the final boss must be defeated. """ - game: str = "Secret of Evermore" - option_definitions = soe_options + game: typing.ClassVar[str] = "Secret of Evermore" + options_dataclass = SoEOptions + options: SoEOptions settings: typing.ClassVar[SoESettings] topology_present = False data_version = 4 @@ -170,31 +173,21 @@ class SoEWorld(World): location_name_to_id, location_id_to_raw = _get_location_mapping() item_name_groups = _get_item_grouping() - trap_types = [name[12:] for name in option_definitions if name.startswith('trap_chance_')] - + logic: SoEPlayerLogic evermizer_seed: int connect_name: str - energy_core: int - sequence_breaks: int - out_of_bounds: int - available_fragments: int - required_fragments: int _halls_ne_chest_names: typing.List[str] = [loc.name for loc in _locations if 'Halls NE' in loc.name] - def __init__(self, *args, **kwargs): + def __init__(self, multiworld: "MultiWorld", player: int): self.connect_name_available_event = threading.Event() - super(SoEWorld, self).__init__(*args, **kwargs) + super(SoEWorld, self).__init__(multiworld, player) def generate_early(self) -> None: - # store option values that change logic - self.energy_core = self.multiworld.energy_core[self.player].value - self.sequence_breaks = self.multiworld.sequence_breaks[self.player].value - self.out_of_bounds = self.multiworld.out_of_bounds[self.player].value - self.required_fragments = self.multiworld.required_fragments[self.player].value - if self.required_fragments > self.multiworld.available_fragments[self.player].value: - self.multiworld.available_fragments[self.player].value = self.required_fragments - self.available_fragments = self.multiworld.available_fragments[self.player].value + # create logic from options + if self.options.required_fragments.value > self.options.available_fragments.value: + self.options.available_fragments.value = self.options.required_fragments.value + self.logic = SoEPlayerLogic(self.player, self.options) def create_event(self, event: str) -> Item: return SoEItem(event, ItemClassification.progression, None, self.player) @@ -214,20 +207,20 @@ def create_item(self, item: typing.Union[pyevermizer.Item, str]) -> Item: return SoEItem(item.name, classification, self.item_name_to_id[item.name], self.player) @classmethod - def stage_assert_generate(cls, multiworld): + def stage_assert_generate(cls, _: "MultiWorld") -> None: rom_file = get_base_rom_path() if not os.path.exists(rom_file): raise FileNotFoundError(rom_file) - def create_regions(self): + def create_regions(self) -> None: # exclude 'hidden' on easy - max_difficulty = 1 if self.multiworld.difficulty[self.player] == Difficulty.option_easy else 256 + max_difficulty = 1 if self.options.difficulty == Difficulty.option_easy else 256 # TODO: generate *some* regions from locations' requirements? menu = Region('Menu', self.player, self.multiworld) self.multiworld.regions += [menu] - def get_sphere_index(evermizer_loc): + def get_sphere_index(evermizer_loc: pyevermizer.Location) -> int: """Returns 0, 1 or 2 for locations in spheres 1, 2, 3+""" if len(evermizer_loc.requires) == 1 and evermizer_loc.requires[0][1] != pyevermizer.P_WEAPON: return 2 @@ -252,18 +245,18 @@ def get_sphere_index(evermizer_loc): # mark some as excluded based on numbers above for trash_sphere, fills in trash_fills.items(): for typ, counts in fills.items(): - count = counts[self.multiworld.difficulty[self.player].value] - for location in self.multiworld.random.sample(spheres[trash_sphere][typ], count): + count = counts[self.options.difficulty.value] + for location in self.random.sample(spheres[trash_sphere][typ], count): assert location.name != "Energy Core #285", "Error in sphere generation" location.progress_type = LocationProgressType.EXCLUDED - def sphere1_blocked_items_rule(item): + def sphere1_blocked_items_rule(item: pyevermizer.Item) -> bool: if isinstance(item, SoEItem): # disable certain items in sphere 1 if item.name in {"Gauge", "Wheel"}: return False # and some more for non-easy, non-mystery - if self.multiworld.difficulty[item.player] not in (Difficulty.option_easy, Difficulty.option_mystery): + if self.options.difficulty not in (Difficulty.option_easy, Difficulty.option_mystery): if item.name in {"Laser Lance", "Atom Smasher", "Diamond Eye"}: return False return True @@ -273,13 +266,13 @@ def sphere1_blocked_items_rule(item): add_item_rule(location, sphere1_blocked_items_rule) # make some logically late(r) bosses priority locations to increase complexity - if self.multiworld.difficulty[self.player] == Difficulty.option_mystery: - late_count = self.multiworld.random.randint(0, 2) + if self.options.difficulty == Difficulty.option_mystery: + late_count = self.random.randint(0, 2) else: - late_count = self.multiworld.difficulty[self.player].value + late_count = self.options.difficulty.value late_bosses = ("Tiny", "Aquagoth", "Megataur", "Rimsala", "Mungola", "Lightning Storm", "Magmar", "Volcano Viper") - late_locations = self.multiworld.random.sample(late_bosses, late_count) + late_locations = self.random.sample(late_bosses, late_count) # add locations to the world for sphere in spheres.values(): @@ -293,17 +286,17 @@ def sphere1_blocked_items_rule(item): menu.connect(ingame, "New Game") self.multiworld.regions += [ingame] - def create_items(self): + def create_items(self) -> None: # add regular items to the pool exclusions: typing.List[str] = [] - if self.energy_core != EnergyCore.option_shuffle: + if self.options.energy_core != EnergyCore.option_shuffle: exclusions.append("Energy Core") # will be placed in generate_basic or replaced by a fragment below items = list(map(lambda item: self.create_item(item), (item for item in _items if item.name not in exclusions))) # remove one pair of wings that will be placed in generate_basic items.remove(self.create_item("Wings")) - def is_ingredient(item): + def is_ingredient(item: pyevermizer.Item) -> bool: for ingredient in _ingredients: if _match_item_name(item, ingredient): return True @@ -311,84 +304,74 @@ def is_ingredient(item): # add energy core fragments to the pool ingredients = [n for n, item in enumerate(items) if is_ingredient(item)] - if self.energy_core == EnergyCore.option_fragments: + if self.options.energy_core == EnergyCore.option_fragments: items.append(self.create_item("Energy Core Fragment")) # replaces the vanilla energy core - for _ in range(self.available_fragments - 1): + for _ in range(self.options.available_fragments - 1): if len(ingredients) < 1: break # out of ingredients to replace - r = self.multiworld.random.choice(ingredients) + r = self.random.choice(ingredients) ingredients.remove(r) items[r] = self.create_item("Energy Core Fragment") # add traps to the pool - trap_count = self.multiworld.trap_count[self.player].value - trap_chances = {} - trap_names = {} + trap_count = self.options.trap_count.value + trap_names: typing.List[str] = [] + trap_weights: typing.List[int] = [] if trap_count > 0: - for trap_type in self.trap_types: - trap_option = getattr(self.multiworld, f'trap_chance_{trap_type}')[self.player] - trap_chances[trap_type] = trap_option.value - trap_names[trap_type] = trap_option.item_name - trap_chances_total = sum(trap_chances.values()) - if trap_chances_total == 0: - for trap_type in trap_chances: - trap_chances[trap_type] = 1 - trap_chances_total = len(trap_chances) + for trap_option in self.options.trap_chances: + trap_names.append(trap_option.item_name) + trap_weights.append(trap_option.value) + if sum(trap_weights) == 0: + trap_weights = [1 for _ in trap_weights] def create_trap() -> Item: - v = self.multiworld.random.randrange(trap_chances_total) - for t, c in trap_chances.items(): - if v < c: - return self.create_item(trap_names[t]) - v -= c - assert False, "Bug in create_trap" + return self.create_item(self.random.choices(trap_names, trap_weights)[0]) for _ in range(trap_count): if len(ingredients) < 1: break # out of ingredients to replace - r = self.multiworld.random.choice(ingredients) + r = self.random.choice(ingredients) ingredients.remove(r) items[r] = create_trap() self.multiworld.itempool += items - def set_rules(self): + def set_rules(self) -> None: self.multiworld.completion_condition[self.player] = lambda state: state.has('Victory', self.player) # set Done from goal option once we have multiple goals set_rule(self.multiworld.get_location('Done', self.player), - lambda state: state.soe_has(pyevermizer.P_FINAL_BOSS, self.multiworld, self.player)) + lambda state: self.logic.has(state, pyevermizer.P_FINAL_BOSS)) set_rule(self.multiworld.get_entrance('New Game', self.player), lambda state: True) for loc in _locations: location = self.multiworld.get_location(loc.name, self.player) set_rule(location, self.make_rule(loc.requires)) def make_rule(self, requires: typing.List[typing.Tuple[int, int]]) -> typing.Callable[[typing.Any], bool]: - def rule(state) -> bool: + def rule(state: "CollectionState") -> bool: for count, progress in requires: - if not state.soe_has(progress, self.multiworld, self.player, count): + if not self.logic.has(state, progress, count): return False return True return rule - def make_item_type_limit_rule(self, item_type: int): - return lambda item: item.player != self.player or self.item_id_to_raw[item.code].type == item_type - - def generate_basic(self): + def generate_basic(self) -> None: # place Victory event self.multiworld.get_location('Done', self.player).place_locked_item(self.create_event('Victory')) # place wings in halls NE to avoid softlock - wings_location = self.multiworld.random.choice(self._halls_ne_chest_names) + wings_location = self.random.choice(self._halls_ne_chest_names) wings_item = self.create_item('Wings') self.multiworld.get_location(wings_location, self.player).place_locked_item(wings_item) # place energy core at vanilla location for vanilla mode - if self.energy_core == EnergyCore.option_vanilla: + if self.options.energy_core == EnergyCore.option_vanilla: energy_core = self.create_item('Energy Core') self.multiworld.get_location('Energy Core #285', self.player).place_locked_item(energy_core) # generate stuff for later - self.evermizer_seed = self.multiworld.random.randint(0, 2 ** 16 - 1) # TODO: make this an option for "full" plando? + self.evermizer_seed = self.random.randint(0, 2 ** 16 - 1) # TODO: make this an option for "full" plando? + + def generate_output(self, output_directory: str) -> None: + from dataclasses import asdict - def generate_output(self, output_directory: str): player_name = self.multiworld.get_player_name(self.player) self.connect_name = player_name[:32] while len(self.connect_name.encode('utf-8')) > 32: @@ -397,24 +380,21 @@ def generate_output(self, output_directory: str): placement_file = "" out_file = "" try: - money = self.multiworld.money_modifier[self.player].value - exp = self.multiworld.exp_modifier[self.player].value + money = self.options.money_modifier.value + exp = self.options.exp_modifier.value switches: typing.List[str] = [] - if self.multiworld.death_link[self.player].value: + if self.options.death_link.value: switches.append("--death-link") - if self.energy_core == EnergyCore.option_fragments: - switches.extend(('--available-fragments', str(self.available_fragments), - '--required-fragments', str(self.required_fragments))) + if self.options.energy_core == EnergyCore.option_fragments: + switches.extend(('--available-fragments', str(self.options.available_fragments.value), + '--required-fragments', str(self.options.required_fragments.value))) rom_file = get_base_rom_path() out_base = output_path(output_directory, self.multiworld.get_out_file_name_base(self.player)) out_file = out_base + '.sfc' placement_file = out_base + '.txt' patch_file = out_base + '.apsoe' flags = 'l' # spoiler log - for option_name in self.option_definitions: - option = getattr(self.multiworld, option_name)[self.player] - if hasattr(option, 'to_flag'): - flags += option.to_flag() + flags += self.options.flags with open(placement_file, "wb") as f: # generate placement file for location in self.multiworld.get_locations(self.player): @@ -448,7 +428,7 @@ def generate_output(self, output_directory: str): except FileNotFoundError: pass - def modify_multidata(self, multidata: dict): + def modify_multidata(self, multidata: typing.Dict[str, typing.Any]) -> None: # wait for self.connect_name to be available. self.connect_name_available_event.wait() # we skip in case of error, so that the original error in the output thread is the one that gets raised @@ -457,7 +437,7 @@ def modify_multidata(self, multidata: dict): multidata["connect_names"][self.connect_name] = payload def get_filler_item_name(self) -> str: - return self.multiworld.random.choice(list(self.item_name_groups["Ingredients"])) + return self.random.choice(list(self.item_name_groups["Ingredients"])) class SoEItem(Item): diff --git a/worlds/soe/logic.py b/worlds/soe/logic.py new file mode 100644 index 000000000000..ee81c76e58de --- /dev/null +++ b/worlds/soe/logic.py @@ -0,0 +1,85 @@ +import typing +from typing import Callable, Set + +from . import pyevermizer +from .options import EnergyCore, OutOfBounds, SequenceBreaks, SoEOptions + +if typing.TYPE_CHECKING: + from BaseClasses import CollectionState + +# TODO: Options may preset certain progress steps (i.e. P_ROCK_SKIP), set in generate_early? + +# TODO: resolve/flatten/expand rules to get rid of recursion below where possible +# Logic.rules are all rules including locations, excluding those with no progress (i.e. locations that only drop items) +rules = [rule for rule in pyevermizer.get_logic() if len(rule.provides) > 0] +# Logic.items are all items and extra items excluding non-progression items and duplicates +item_names: Set[str] = set() +items = [item for item in filter(lambda item: item.progression, pyevermizer.get_items() + pyevermizer.get_extra_items()) + if item.name not in item_names and not item_names.add(item.name)] # type: ignore[func-returns-value] + + +class SoEPlayerLogic: + __slots__ = "player", "out_of_bounds", "sequence_breaks", "has" + player: int + out_of_bounds: bool + sequence_breaks: bool + + has: Callable[..., bool] + """ + Returns True if count of one of evermizer's progress steps is reached based on collected items. i.e. 2 * P_DE + """ + + def __init__(self, player: int, options: "SoEOptions"): + self.player = player + self.out_of_bounds = options.out_of_bounds == OutOfBounds.option_logic + self.sequence_breaks = options.sequence_breaks == SequenceBreaks.option_logic + + if options.energy_core == EnergyCore.option_fragments: + # override logic for energy core fragments + required_fragments = options.required_fragments.value + + def fragmented_has(state: "CollectionState", progress: int, count: int = 1) -> bool: + if progress == pyevermizer.P_ENERGY_CORE: + progress = pyevermizer.P_CORE_FRAGMENT + count = required_fragments + return self._has(state, progress, count) + + self.has = fragmented_has + else: + # default (energy core) logic + self.has = self._has + + def _count(self, state: "CollectionState", progress: int, max_count: int = 0) -> int: + """ + Returns reached count of one of evermizer's progress steps based on collected items. + i.e. returns 0-3 for P_DE based on items providing CHECK_BOSS,DIAMOND_EYE_DROP + """ + n = 0 + for item in items: + for pvd in item.provides: + if pvd[1] == progress: + if state.has(item.name, self.player): + n += state.count(item.name, self.player) * pvd[0] + if n >= max_count > 0: + return n + for rule in rules: + for pvd in rule.provides: + if pvd[1] == progress and pvd[0] > 0: + has = True + for req in rule.requires: + if not self.has(state, req[1], req[0]): + has = False + break + if has: + n += pvd[0] + if n >= max_count > 0: + return n + return n + + def _has(self, state: "CollectionState", progress: int, count: int = 1) -> bool: + """Default implementation of has""" + if self.out_of_bounds is True and progress == pyevermizer.P_ALLOW_OOB: + return True + if self.sequence_breaks is True and progress == pyevermizer.P_ALLOW_SEQUENCE_BREAKS: + return True + return self._count(state, progress, count) >= count diff --git a/worlds/soe/Options.py b/worlds/soe/options.py similarity index 71% rename from worlds/soe/Options.py rename to worlds/soe/options.py index 3de2de34ac67..0436b17618e7 100644 --- a/worlds/soe/Options.py +++ b/worlds/soe/options.py @@ -1,16 +1,18 @@ -import typing +from dataclasses import dataclass, fields +from typing import Any, cast, Dict, Iterator, List, Tuple, Protocol -from Options import Range, Choice, Toggle, DefaultOnToggle, AssembleOptions, DeathLink, ProgressionBalancing +from Options import AssembleOptions, Choice, DeathLink, DefaultOnToggle, PerGameCommonOptions, ProgressionBalancing, \ + Range, Toggle # typing boilerplate -class FlagsProtocol(typing.Protocol): +class FlagsProtocol(Protocol): value: int default: int - flags: typing.List[str] + flags: List[str] -class FlagProtocol(typing.Protocol): +class FlagProtocol(Protocol): value: int default: int flag: str @@ -18,7 +20,7 @@ class FlagProtocol(typing.Protocol): # meta options class EvermizerFlags: - flags: typing.List[str] + flags: List[str] def to_flag(self: FlagsProtocol) -> str: return self.flags[self.value] @@ -200,13 +202,13 @@ class TrapCount(Range): # more meta options class ItemChanceMeta(AssembleOptions): - def __new__(mcs, name, bases, attrs): + def __new__(mcs, name: str, bases: Tuple[type], attrs: Dict[Any, Any]) -> "ItemChanceMeta": if 'item_name' in attrs: attrs["display_name"] = f"{attrs['item_name']} Chance" attrs["range_start"] = 0 attrs["range_end"] = 100 - - return super(ItemChanceMeta, mcs).__new__(mcs, name, bases, attrs) + cls = super(ItemChanceMeta, mcs).__new__(mcs, name, bases, attrs) + return cast(ItemChanceMeta, cls) class TrapChance(Range, metaclass=ItemChanceMeta): @@ -247,33 +249,50 @@ class SoEProgressionBalancing(ProgressionBalancing): special_range_names = {**ProgressionBalancing.special_range_names, "normal": default} -soe_options: typing.Dict[str, AssembleOptions] = { - "difficulty": Difficulty, - "energy_core": EnergyCore, - "required_fragments": RequiredFragments, - "available_fragments": AvailableFragments, - "money_modifier": MoneyModifier, - "exp_modifier": ExpModifier, - "sequence_breaks": SequenceBreaks, - "out_of_bounds": OutOfBounds, - "fix_cheats": FixCheats, - "fix_infinite_ammo": FixInfiniteAmmo, - "fix_atlas_glitch": FixAtlasGlitch, - "fix_wings_glitch": FixWingsGlitch, - "shorter_dialogs": ShorterDialogs, - "short_boss_rush": ShortBossRush, - "ingredienizer": Ingredienizer, - "sniffamizer": Sniffamizer, - "callbeadamizer": Callbeadamizer, - "musicmizer": Musicmizer, - "doggomizer": Doggomizer, - "turdo_mode": TurdoMode, - "death_link": DeathLink, - "trap_count": TrapCount, - "trap_chance_quake": TrapChanceQuake, - "trap_chance_poison": TrapChancePoison, - "trap_chance_confound": TrapChanceConfound, - "trap_chance_hud": TrapChanceHUD, - "trap_chance_ohko": TrapChanceOHKO, - "progression_balancing": SoEProgressionBalancing, -} +# noinspection SpellCheckingInspection +@dataclass +class SoEOptions(PerGameCommonOptions): + difficulty: Difficulty + energy_core: EnergyCore + required_fragments: RequiredFragments + available_fragments: AvailableFragments + money_modifier: MoneyModifier + exp_modifier: ExpModifier + sequence_breaks: SequenceBreaks + out_of_bounds: OutOfBounds + fix_cheats: FixCheats + fix_infinite_ammo: FixInfiniteAmmo + fix_atlas_glitch: FixAtlasGlitch + fix_wings_glitch: FixWingsGlitch + shorter_dialogs: ShorterDialogs + short_boss_rush: ShortBossRush + ingredienizer: Ingredienizer + sniffamizer: Sniffamizer + callbeadamizer: Callbeadamizer + musicmizer: Musicmizer + doggomizer: Doggomizer + turdo_mode: TurdoMode + death_link: DeathLink + trap_count: TrapCount + trap_chance_quake: TrapChanceQuake + trap_chance_poison: TrapChancePoison + trap_chance_confound: TrapChanceConfound + trap_chance_hud: TrapChanceHUD + trap_chance_ohko: TrapChanceOHKO + progression_balancing: SoEProgressionBalancing + + @property + def trap_chances(self) -> Iterator[TrapChance]: + for field in fields(self): + option = getattr(self, field.name) + if isinstance(option, TrapChance): + yield option + + @property + def flags(self) -> str: + flags = '' + for field in fields(self): + option = getattr(self, field.name) + if isinstance(option, (EvermizerFlag, EvermizerFlags)): + flags += getattr(self, field.name).to_flag() + return flags diff --git a/worlds/soe/Patch.py b/worlds/soe/patch.py similarity index 86% rename from worlds/soe/Patch.py rename to worlds/soe/patch.py index f4de5d06ead1..8270f2d86dfa 100644 --- a/worlds/soe/Patch.py +++ b/worlds/soe/patch.py @@ -1,5 +1,5 @@ import os -from typing import Optional +from typing import BinaryIO, Optional import Utils from worlds.Files import APDeltaPatch @@ -30,7 +30,7 @@ def get_base_rom_path(file_name: Optional[str] = None) -> str: return file_name -def read_rom(stream, strip_header=True) -> bytes: +def read_rom(stream: BinaryIO, strip_header: bool=True) -> bytes: """Reads rom into bytearray and optionally strips off any smc header""" data = stream.read() if strip_header and len(data) % 0x400 == 0x200: @@ -40,5 +40,5 @@ def read_rom(stream, strip_header=True) -> bytes: if __name__ == '__main__': import sys - print('Please use ../../Patch.py', file=sys.stderr) + print('Please use ../../patch.py', file=sys.stderr) sys.exit(1) diff --git a/worlds/soe/test/__init__.py b/worlds/soe/test/__init__.py index 27d38605aae4..b3ba7018e48d 100644 --- a/worlds/soe/test/__init__.py +++ b/worlds/soe/test/__init__.py @@ -1,4 +1,4 @@ -from test.TestBase import WorldTestBase +from test.bases import WorldTestBase from typing import Iterable @@ -18,3 +18,14 @@ def assertLocationReachability(self, reachable: Iterable[str] = (), unreachable: for location in unreachable: self.assertFalse(self.can_reach_location(location), f"{location} is reachable but shouldn't be") + + def testRocketPartsExist(self): + """Tests that rocket parts exist and are unique""" + self.assertEqual(len(self.get_items_by_name("Gauge")), 1) + self.assertEqual(len(self.get_items_by_name("Wheel")), 1) + diamond_eyes = self.get_items_by_name("Diamond Eye") + self.assertEqual(len(diamond_eyes), 3) + # verify diamond eyes are individual items + self.assertFalse(diamond_eyes[0] is diamond_eyes[1]) + self.assertFalse(diamond_eyes[0] is diamond_eyes[2]) + self.assertFalse(diamond_eyes[1] is diamond_eyes[2]) diff --git a/worlds/soe/test/test_access.py b/worlds/soe/test/test_access.py index c7da7b889627..81b8818eb528 100644 --- a/worlds/soe/test/test_access.py +++ b/worlds/soe/test/test_access.py @@ -7,7 +7,7 @@ class AccessTest(SoETestBase): def _resolveGourds(gourds: typing.Dict[str, typing.Iterable[int]]): return [f"{name} #{number}" for name, numbers in gourds.items() for number in numbers] - def testBronzeAxe(self): + def test_bronze_axe(self): gourds = { "Pyramid bottom": (118, 121, 122, 123, 124, 125), "Pyramid top": (140,) @@ -16,7 +16,7 @@ def testBronzeAxe(self): items = [["Bronze Axe"]] self.assertAccessDependency(locations, items) - def testBronzeSpearPlus(self): + def test_bronze_spear_plus(self): locations = ["Megataur"] items = [["Bronze Spear"], ["Lance (Weapon)"], ["Laser Lance"]] self.assertAccessDependency(locations, items) diff --git a/worlds/soe/test/test_goal.py b/worlds/soe/test/test_goal.py index d127d3899869..885c2a74ef14 100644 --- a/worlds/soe/test/test_goal.py +++ b/worlds/soe/test/test_goal.py @@ -8,7 +8,7 @@ class TestFragmentGoal(SoETestBase): "required_fragments": 20, } - def testFragments(self): + def test_fragments(self): self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"]) self.assertBeatable(False) # 0 fragments fragments = self.get_items_by_name("Energy Core Fragment") @@ -24,11 +24,11 @@ def testFragments(self): self.assertEqual(self.count("Energy Core Fragment"), 21) self.assertBeatable(True) - def testNoWeapon(self): + def test_no_weapon(self): self.collect_by_name(["Diamond Eye", "Wheel", "Gauge", "Energy Core Fragment"]) self.assertBeatable(False) - def testNoRocket(self): + def test_no_rocket(self): self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Energy Core Fragment"]) self.assertBeatable(False) @@ -38,16 +38,16 @@ class TestShuffleGoal(SoETestBase): "energy_core": "shuffle", } - def testCore(self): + def test_core(self): self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"]) self.assertBeatable(False) self.collect_by_name(["Energy Core"]) self.assertBeatable(True) - def testNoWeapon(self): + def test_no_weapon(self): self.collect_by_name(["Diamond Eye", "Wheel", "Gauge", "Energy Core"]) self.assertBeatable(False) - def testNoRocket(self): + def test_no_rocket(self): self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Energy Core"]) self.assertBeatable(False) diff --git a/worlds/soe/test/test_oob.py b/worlds/soe/test/test_oob.py index 27e00cd3e764..969e93d4f6af 100644 --- a/worlds/soe/test/test_oob.py +++ b/worlds/soe/test/test_oob.py @@ -6,7 +6,7 @@ class OoBTest(SoETestBase): """Tests that 'on' doesn't put out-of-bounds in logic. This is also the test base for OoB in logic.""" options: typing.Dict[str, typing.Any] = {"out_of_bounds": "on"} - def testOoBAccess(self): + def test_oob_access(self): in_logic = self.options["out_of_bounds"] == "logic" # some locations that just need a weapon + OoB @@ -37,7 +37,7 @@ def testOoBAccess(self): self.collect_by_name("Diamond Eye") self.assertLocationReachability(reachable=de_reachable, unreachable=de_unreachable, satisfied=in_logic) - def testOoBGoal(self): + def test_oob_goal(self): # still need Energy Core with OoB if sequence breaks are not in logic for item in ["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"]: self.collect_by_name(item) diff --git a/worlds/soe/test/test_sequence_breaks.py b/worlds/soe/test/test_sequence_breaks.py index 4248f9b47d97..8a7f9c64ede8 100644 --- a/worlds/soe/test/test_sequence_breaks.py +++ b/worlds/soe/test/test_sequence_breaks.py @@ -6,7 +6,7 @@ class SequenceBreaksTest(SoETestBase): """Tests that 'on' doesn't put sequence breaks in logic. This is also the test base for in-logic.""" options: typing.Dict[str, typing.Any] = {"sequence_breaks": "on"} - def testSequenceBreaksAccess(self): + def test_sequence_breaks_access(self): in_logic = self.options["sequence_breaks"] == "logic" # some locations that just need any weapon + sequence break @@ -30,7 +30,7 @@ def testSequenceBreaksAccess(self): self.collect_by_name("Bronze Spear") # Escape now just needs either Megataur or Rimsala dead self.assertEqual(self.can_reach_location("Escape"), in_logic) - def testSequenceBreaksGoal(self): + def test_sequence_breaks_goal(self): in_logic = self.options["sequence_breaks"] == "logic" # don't need Energy Core with sequence breaks in logic diff --git a/worlds/soe/test/test_traps.py b/worlds/soe/test/test_traps.py new file mode 100644 index 000000000000..f83a37be8223 --- /dev/null +++ b/worlds/soe/test/test_traps.py @@ -0,0 +1,55 @@ +import typing +from dataclasses import fields + +from . import SoETestBase +from ..options import SoEOptions + +if typing.TYPE_CHECKING: + from .. import SoEWorld + + +class Bases: + # class in class to avoid running tests for TrapTest class + class TrapTestBase(SoETestBase): + """Test base for trap tests""" + option_name_to_item_name = { + # filtering by name here validates that there is no confusion between name and type + field.name: field.type.item_name for field in fields(SoEOptions) if field.name.startswith("trap_chance_") + } + + def test_dataclass(self) -> None: + """Test that the dataclass helper property returns the expected sequence""" + self.assertGreater(len(self.option_name_to_item_name), 0, "Expected more than 0 trap types") + world: "SoEWorld" = typing.cast("SoEWorld", self.multiworld.worlds[1]) + item_name_to_rolled_option = {option.item_name: option for option in world.options.trap_chances} + # compare that all fields are present - that is property in dataclass and selector code in test line up + self.assertEqual(sorted(self.option_name_to_item_name.values()), sorted(item_name_to_rolled_option), + "field names probably do not match field types") + # sanity check that chances are correctly set and returned by property + for option_name, item_name in self.option_name_to_item_name.items(): + self.assertEqual(item_name_to_rolled_option[item_name].value, + self.options.get(option_name, item_name_to_rolled_option[item_name].default)) + + def test_trap_count(self) -> None: + """Test that total trap count is correct""" + self.assertEqual(self.options["trap_count"], len(self.get_items_by_name(self.option_name_to_item_name.values()))) + + +class TestTrapAllZeroChance(Bases.TrapTestBase): + """Tests all zero chances still gives traps if trap_count is set.""" + options: typing.Dict[str, typing.Any] = { + "trap_count": 1, + **{name: 0 for name in Bases.TrapTestBase.option_name_to_item_name} + } + + +class TestTrapNoConfound(Bases.TrapTestBase): + """Tests that one zero chance does not give that trap.""" + options: typing.Dict[str, typing.Any] = { + "trap_count": 99, + "trap_chance_confound": 0, + } + + def test_no_confound_trap(self) -> None: + self.assertEqual(self.option_name_to_item_name["trap_chance_confound"], "Confound Trap") + self.assertEqual(len(self.get_items_by_name("Confound Trap")), 0)